Passed
Push — master ( e3c27b...b957a6 )
by Adrien
17:10
created

Importer::loadAccount()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 11
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 7
CRAP Score 2

Importance

Changes 0
Metric Value
eloc 6
dl 0
loc 11
rs 10
c 0
b 0
f 0
ccs 7
cts 7
cp 1
cc 2
nc 2
nop 1
crap 2
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Application\Service;
6
7
use Application\Model\Account;
8
use Application\Model\Transaction;
9
use Application\Model\TransactionLine;
10
use Application\Model\User;
11
use Application\Repository\AccountRepository;
12
use Application\Repository\TransactionLineRepository;
13
use Application\Repository\UserRepository;
14
use Cake\Chronos\Chronos;
15
use Ecodev\Felix\Api\Exception;
16
use Ecodev\Felix\Service\Bvr;
17
use Genkgo\Camt\Camt054\MessageFormat\V02;
18
use Genkgo\Camt\Camt054\MessageFormat\V04;
19
use Genkgo\Camt\Config;
20
use Genkgo\Camt\DTO\Address;
21
use Genkgo\Camt\DTO\Entry;
22
use Genkgo\Camt\DTO\EntryTransactionDetail;
23
use Genkgo\Camt\DTO\Message;
24
use Genkgo\Camt\DTO\Record;
25
use Genkgo\Camt\DTO\RelatedPartyTypeInterface;
26
use Genkgo\Camt\Exception\ReaderException;
27
use Genkgo\Camt\Reader;
28
29
/**
30
 * This service allows to import a CAMT file as Transaction and TransactionLine.
31
 *
32
 * @see https://www.six-group.com/interbank-clearing/dam/downloads/en/standardization/iso/swiss-recommendations/archives/implementation-guidelines-cm/standardization_isopayments_iso_20022_ch_implementation_guidelines_camt.pdf
33
 */
34
class Importer
35
{
36
    private Message $message;
37
38
    /**
39
     * @var Transaction[]
40
     */
41
    private array $transactions = [];
42
43
    private Account $bankAccount;
44
45
    private readonly AccountRepository $accountRepository;
46
47
    private readonly UserRepository $userRepository;
48
49
    private readonly TransactionLineRepository $transactionLineRepository;
50
51 16
    public function __construct()
52
    {
53 16
        $this->accountRepository = _em()->getRepository(Account::class);
0 ignored issues
show
Bug introduced by
The property accountRepository is declared read-only in Application\Service\Importer.
Loading history...
54 16
        $this->userRepository = _em()->getRepository(User::class);
0 ignored issues
show
Bug introduced by
The property userRepository is declared read-only in Application\Service\Importer.
Loading history...
55 16
        $this->transactionLineRepository = _em()->getRepository(TransactionLine::class);
0 ignored issues
show
Bug introduced by
The property transactionLineRepository is declared read-only in Application\Service\Importer.
Loading history...
56
    }
57
58
    /**
59
     * Import all transactions from a CAMT file.
60
     *
61
     * @return Transaction[]
62
     */
63 16
    public function import(string $file): array
64
    {
65 16
        $this->transactions = [];
66 16
        $reader = new Reader(Config::getDefault());
67
68
        try {
69 16
            $this->message = $reader->readFile($file);
70 2
        } catch (ReaderException $exception) {
71 2
            throw new Exception($exception->getMessage(), 0, $exception);
72
        }
73
74 14
        $this->validateFormat($reader);
75
76 13
        $this->userRepository->getAclFilter()->runWithoutAcl(function (): void {
77 13
            $records = $this->message->getRecords();
78 13
            foreach ($records as $record) {
79 13
                $this->bankAccount = $this->loadAccount($record);
80
81 12
                foreach ($record->getEntries() as $entry) {
82 12
                    $this->importTransaction($entry);
83
                }
84
            }
85
        });
86
87 10
        return $this->transactions;
88
    }
89
90 14
    private function validateFormat(Reader $reader): void
91
    {
92 14
        $messageFormat = $reader->getMessageFormat();
93 14
        if (!$messageFormat) {
94
            // This should actually never happen, because the reader would throw an exception before here
95
            throw new Exception('Unknown XML format');
96
        }
97
98 14
        $expected = [
99
            V02::class,
100
            V04::class,
101
        ];
102
103 14
        if (!in_array($messageFormat::class, $expected, true)) {
104 1
            throw new Exception('The format CAMT 054 is expected, but instead we got: ' . $messageFormat->getMsgId());
105
        }
106
    }
107
108 12
    private function importTransaction(Entry $entry): void
109
    {
110 12
        $nativeDate = $entry->getValueDate();
111 12
        $date = Chronos::instance($nativeDate);
0 ignored issues
show
Bug introduced by
It seems like $nativeDate can also be of type null; however, parameter $dt of Cake\Chronos\Chronos::instance() does only seem to accept DateTimeInterface, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

111
        $date = Chronos::instance(/** @scrutinizer ignore-type */ $nativeDate);
Loading history...
112
113 12
        $transaction = new Transaction();
114 12
        $transaction->setName('Versement BVR');
115 12
        $transaction->setTransactionDate($date);
116
117 12
        $internalRemarks = [];
118 12
        $mustUseSuffix = $this->mustUseSuffix($entry->getTransactionDetails());
119 12
        foreach ($entry->getTransactionDetails() as $i => $detail) {
120 12
            $suffix = $mustUseSuffix ? '-epicerio-' . ($i + 1) : '';
121 12
            $internalRemarks[] = $this->importTransactionLine($transaction, $detail, $suffix);
122
        }
123 10
        $transaction->setInternalRemarks(implode(PHP_EOL . PHP_EOL, $internalRemarks));
124
125
        // Don't persist transaction that may not have any lines
126 10
        $transactionLines = $transaction->getTransactionLines();
127 10
        if ($transactionLines->count()) {
128
            // Use same owner for line and transaction
129 9
            $transaction->setOwner($transactionLines->first()->getOwner());
130
131 9
            _em()->persist($transaction);
132 9
            $this->transactions[] = $transaction;
133
        }
134
    }
135
136 12
    private function importTransactionLine(Transaction $transaction, EntryTransactionDetail $detail, string $suffix): string
137
    {
138 12
        $importedId = $this->getImportedId($detail, $suffix);
139 11
        $transactionDate = $transaction->getTransactionDate();
140 11
        if ($this->transactionLineRepository->importedExists($importedId, $transactionDate)) {
141 3
            return '';
142
        }
143
144 10
        $referenceNumber = $detail->getRemittanceInformation()->getStructuredBlock()->getCreditorReferenceInformation()->getRef();
145 10
        $user = $this->loadUser($referenceNumber);
0 ignored issues
show
Bug introduced by
It seems like $referenceNumber can also be of type null; however, parameter $referenceNumber of Application\Service\Importer::loadUser() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

145
        $user = $this->loadUser(/** @scrutinizer ignore-type */ $referenceNumber);
Loading history...
146 9
        $userAccount = $this->accountRepository->getOrCreate($user);
147 9
        $remarks = $this->getRemarks($detail, $referenceNumber);
148 9
        $amount = $detail->getAmount();
149
150 9
        $line = new TransactionLine();
151 9
        $line->setTransaction($transaction);
152 9
        $line->setOwner($user);
153 9
        $line->setName('Versement BVR');
154 9
        $line->setTransactionDate($transactionDate);
155 9
        $line->setBalance($amount);
0 ignored issues
show
Bug introduced by
It seems like $amount can also be of type null; however, parameter $balance of Application\Model\TransactionLine::setBalance() does only seem to accept Money\Money, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

155
        $line->setBalance(/** @scrutinizer ignore-type */ $amount);
Loading history...
156 9
        $line->setCredit($userAccount);
157 9
        $line->setDebit($this->bankAccount);
158 9
        $line->setImportedId($importedId);
159
160 9
        _em()->persist($line);
161
162 9
        return $remarks;
163
    }
164
165 2
    private function partyToString(RelatedPartyTypeInterface $party): string
166
    {
167 2
        $parts = [];
168 2
        $parts[] = $this->partyLabel($party);
169 2
        $parts[] = $party->getName();
170
171 2
        $address = $party->getAddress();
172 2
        if ($address) {
173 2
            $parts[] = $this->addressToString($address);
174
        }
175
176 2
        return implode(PHP_EOL, $parts);
177
    }
178
179 2
    private function partyLabel(RelatedPartyTypeInterface $party): string
180
    {
181 2
        $class = $party::class;
182
183 2
        return match ($class) {
184
            \Genkgo\Camt\DTO\Recipient::class => 'Récipient',
185 2
            \Genkgo\Camt\DTO\Debtor::class => 'Débiteur',
186 2
            \Genkgo\Camt\DTO\Creditor::class => 'Créancier',
187
            \Genkgo\Camt\DTO\UltimateDebtor::class => 'Débiteur final',
188
            \Genkgo\Camt\DTO\UltimateCreditor::class => 'Créancier final',
189 2
            default => throw new Exception('Non supported related party type: ' . $class),
190
        };
191
    }
192
193 2
    private function addressToString(Address $a): string
194
    {
195 2
        $lines = [];
196 2
        $lines[] = trim($a->getStreetName() . ' ' . $a->getBuildingNumber());
197 2
        $lines[] = trim($a->getPostCode() . ' ' . $a->getTownName());
198 2
        $lines[] = $a->getCountry();
199 2
        $lines = array_merge($lines, $a->getAddressLines());
200
201 2
        $nonEmptyLines = array_filter($lines);
202
203 2
        return implode(PHP_EOL, $nonEmptyLines);
204
    }
205
206 9
    private function getRemarks(EntryTransactionDetail $detail, string $referenceNumber): string
207
    {
208 9
        $parts = [];
209 9
        $parts[] = 'Numéro de référence: ' . $referenceNumber;
210
211 9
        foreach ($detail->getRelatedParties() as $party) {
212 2
            $partyDetail = $party->getRelatedPartyType();
213 2
            $parts[] = $this->partyToString($partyDetail);
214
        }
215
216 9
        $remarks = implode(PHP_EOL . PHP_EOL, $parts);
217
218 9
        return $remarks;
219
    }
220
221 13
    private function loadAccount(Record $record): Account
222
    {
223 13
        $accountFromFile = $record->getAccount();
224 13
        $iban = $accountFromFile->getIdentification();
225 13
        $account = $this->accountRepository->findOneByIban($iban);
0 ignored issues
show
Bug introduced by
The method findOneByIban() does not exist on Application\Repository\AccountRepository. Since you implemented __call, consider adding a @method annotation. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

225
        /** @scrutinizer ignore-call */ 
226
        $account = $this->accountRepository->findOneByIban($iban);
Loading history...
226
227 13
        if (!$account) {
228 1
            throw new Exception('The CAMT file contains a statement for account with IBAN `' . $iban . '`, but no account exist for that IBAN in the database. Either create/update a corresponding account, or import a different CAMT file.');
229
        }
230
231 12
        return $account;
232
    }
233
234 10
    private function loadUser(string $referenceNumber): User
235
    {
236 10
        $userId = (int) Bvr::extractCustomId($referenceNumber);
237 10
        $user = $this->userRepository->getOneById($userId);
238
239 10
        if (!$user) {
240 1
            throw new Exception('Could not find a matching user for reference number `' . $referenceNumber . '` and user ID `' . $userId . '`.');
241
        }
242
243 9
        return $user;
244
    }
245
246
    /**
247
     * This must return a non-empty universally unique identifier for one detail.
248
     */
249 12
    private function getImportedId(EntryTransactionDetail $detail, string $suffix): string
250
    {
251 12
        $reference = $detail->getReference();
252
253 12
        $endToEndId = $reference?->getEndToEndId();
254 12
        if ($endToEndId === 'NOTPROVIDED') {
255 2
            $endToEndId = null;
256
        }
257
258 12
        if (!$endToEndId) {
259 11
            $accountServicerReference = $reference?->getAccountServicerReference();
260 11
            if ($accountServicerReference) {
261 10
                $endToEndId = $accountServicerReference . $suffix;
262
            }
263
        }
264
265 12
        if (!$endToEndId) {
266 2
            $endToEndId = $reference?->getMessageId();
267
        }
268
269 12
        if (!$endToEndId) {
270 1
            throw new Exception('Cannot import a transaction without unique universal identifier (<EndToEndId>, <AcctSvcrRef> or <MsgId>).');
271
        }
272
273 11
        return $endToEndId;
274
    }
275
276
    /**
277
     * If at least two details have the same AccountServicerReference, then we must use suffix.
278
     *
279
     * @param EntryTransactionDetail[] $transactionDetails
280
     */
281 12
    private function mustUseSuffix(array $transactionDetails): bool
282
    {
283 12
        if (count($transactionDetails) <= 1) {
284 11
            return false;
285
        }
286
287 2
        $seenValues = [];
288 2
        foreach ($transactionDetails as $transactionDetail) {
289 2
            $value = $transactionDetail->getReference()?->getAccountServicerReference();
290 2
            if (!$value) {
291 1
                continue;
292
            }
293
294 2
            if (in_array($value, $seenValues, true)) {
295 1
                return true;
296
            }
297
298 2
            $seenValues[] = $value;
299
        }
300
301 2
        return false;
302
    }
303
}
304