Importer::loadUser()   A
last analyzed

Complexity

Conditions 2
Paths 2

Size

Total Lines 10
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 6
CRAP Score 2

Importance

Changes 0
Metric Value
eloc 5
dl 0
loc 10
rs 10
c 0
b 0
f 0
ccs 6
cts 6
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\Camt054\MessageFormat\V08;
20
use Genkgo\Camt\Config;
21
use Genkgo\Camt\DTO\Address;
22
use Genkgo\Camt\DTO\Entry;
23
use Genkgo\Camt\DTO\EntryTransactionDetail;
24
use Genkgo\Camt\DTO\Message;
25
use Genkgo\Camt\DTO\Record;
26
use Genkgo\Camt\DTO\RelatedPartyTypeInterface;
27
use Genkgo\Camt\Exception\ReaderException;
28
use Genkgo\Camt\Reader;
29
30
/**
31
 * This service allows to import a CAMT file as Transaction and TransactionLine.
32
 *
33
 * @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
34
 */
35
class Importer
36
{
37
    private Message $message;
38
39
    /**
40
     * @var Transaction[]
41
     */
42
    private array $transactions = [];
43
44
    private Account $bankAccount;
45
46
    private readonly AccountRepository $accountRepository;
47
48
    private readonly UserRepository $userRepository;
49
50
    private readonly TransactionLineRepository $transactionLineRepository;
51
52 17
    public function __construct()
53
    {
54 17
        $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...
55 17
        $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...
56 17
        $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...
57
    }
58
59
    /**
60
     * Import all transactions from a CAMT file.
61
     *
62
     * @return Transaction[]
63
     */
64 17
    public function import(string $file): array
65
    {
66 17
        $this->transactions = [];
67 17
        $reader = new Reader(Config::getDefault());
68
69
        try {
70 17
            $this->message = $reader->readFile($file);
71 2
        } catch (ReaderException $exception) {
72 2
            throw new Exception($exception->getMessage(), 0, $exception);
73
        }
74
75 15
        $this->validateFormat($reader);
76
77 14
        $this->userRepository->getAclFilter()->runWithoutAcl(function (): void {
78 14
            $records = $this->message->getRecords();
79 14
            foreach ($records as $record) {
80 14
                $this->bankAccount = $this->loadAccount($record);
81
82 13
                foreach ($record->getEntries() as $entry) {
83 13
                    $this->importTransaction($entry);
84
                }
85
            }
86 14
        });
87
88 11
        return $this->transactions;
89
    }
90
91 15
    private function validateFormat(Reader $reader): void
92
    {
93 15
        $messageFormat = $reader->getMessageFormat();
94 15
        if (!$messageFormat) {
95
            // This should actually never happen, because the reader would throw an exception before here
96
            throw new Exception('Unknown XML format');
97
        }
98
99 15
        $expected = [
100 15
            V02::class,
101 15
            V04::class,
102 15
            V08::class,
103 15
        ];
104
105 15
        if (!in_array($messageFormat::class, $expected, true)) {
106 1
            throw new Exception('The format CAMT 054 is expected, but instead we got: ' . $messageFormat->getMsgId());
107
        }
108
    }
109
110 13
    private function importTransaction(Entry $entry): void
111
    {
112 13
        $nativeDate = $entry->getValueDate();
113 13
        $date = new Chronos($nativeDate);
114
115 13
        $transaction = new Transaction();
116 13
        $transaction->setName('Versement BVR');
117 13
        $transaction->setTransactionDate($date);
118
119 13
        $internalRemarks = [];
120 13
        $mustUseSuffix = $this->mustUseSuffix($entry->getTransactionDetails());
121 13
        foreach ($entry->getTransactionDetails() as $i => $detail) {
122 13
            $suffix = $mustUseSuffix ? '-epicerio-' . ($i + 1) : '';
123 13
            $internalRemarks[] = $this->importTransactionLine($transaction, $detail, $suffix);
124
        }
125 11
        $transaction->setInternalRemarks(implode(PHP_EOL . PHP_EOL, $internalRemarks));
126
127
        // Don't persist transaction that may not have any lines
128 11
        $transactionLines = $transaction->getTransactionLines();
129 11
        if ($transactionLines->count()) {
130
            // Use same owner for line and transaction
131 10
            $transaction->setOwner($transactionLines->first()->getOwner());
132
133 10
            _em()->persist($transaction);
134 10
            $this->transactions[] = $transaction;
135
        }
136
    }
137
138 13
    private function importTransactionLine(Transaction $transaction, EntryTransactionDetail $detail, string $suffix): string
139
    {
140 13
        $importedId = $this->getImportedId($detail, $suffix);
141 12
        $transactionDate = $transaction->getTransactionDate();
142 12
        if ($this->transactionLineRepository->importedExists($importedId, $transactionDate)) {
143 3
            return '';
144
        }
145
146 11
        $referenceNumber = $detail->getRemittanceInformation()->getStructuredBlock()->getCreditorReferenceInformation()->getRef();
147 11
        $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

147
        $user = $this->loadUser(/** @scrutinizer ignore-type */ $referenceNumber);
Loading history...
148 10
        $userAccount = $this->accountRepository->getOrCreate($user);
149 10
        $remarks = $this->getRemarks($detail, $referenceNumber);
150 10
        $amount = $detail->getAmount();
151
152 10
        $line = new TransactionLine();
153 10
        $line->setTransaction($transaction);
154 10
        $line->setOwner($user);
155 10
        $line->setName('Versement BVR');
156 10
        $line->setTransactionDate($transactionDate);
157 10
        $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

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

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