Failed Conditions
Push — master ( f87f81...988ef6 )
by Adrien
10:33
created

Importer::getEndToEndId()   A

Complexity

Conditions 5
Paths 6

Size

Total Lines 18
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 10
CRAP Score 5

Importance

Changes 0
Metric Value
eloc 9
c 0
b 0
f 0
dl 0
loc 18
ccs 10
cts 10
cp 1
rs 9.6111
cc 5
nc 6
nop 1
crap 5
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
    /**
37
     * @var Message
38
     */
39
    private $message;
40
41
    /**
42
     * @var Transaction[]
43
     */
44
    private $transactions = [];
45
46
    /**
47
     * @var Account
48
     */
49
    private $bankAccount;
50
51
    /**
52
     * @var AccountRepository
53
     */
54
    private $accountRepository;
55
56
    /**
57
     * @var UserRepository
58
     */
59
    private $userRepository;
60
61
    private TransactionLineRepository $transactionLineRepository;
62
63 15
    public function __construct()
64
    {
65 15
        $this->accountRepository = _em()->getRepository(Account::class);
66 15
        $this->userRepository = _em()->getRepository(User::class);
67 15
        $this->transactionLineRepository = _em()->getRepository(TransactionLine::class);
68 15
    }
69
70
    /**
71
     * Import all transactions from a CAMT file
72
     *
73
     * @return Transaction[]
74
     */
75 15
    public function import(string $file): array
76
    {
77 15
        $this->transactions = [];
78 15
        $reader = new Reader(Config::getDefault());
79
80
        try {
81 15
            $this->message = $reader->readFile($file);
82 2
        } catch (ReaderException $exception) {
83 2
            throw new Exception($exception->getMessage(), 0, $exception);
84
        }
85
86 13
        $this->validateFormat($reader);
87
88 12
        $this->userRepository->getAclFilter()->runWithoutAcl(function (): void {
89 12
            $records = $this->message->getRecords();
90 12
            foreach ($records as $record) {
91 12
                $this->bankAccount = $this->loadAccount($record);
92
93 11
                foreach ($record->getEntries() as $entry) {
94 11
                    $this->importTransaction($entry);
95
                }
96
            }
97 12
        });
98
99 9
        return $this->transactions;
100
    }
101
102 13
    private function validateFormat(Reader $reader): void
103
    {
104 13
        $messageFormat = $reader->getMessageFormat();
105 13
        if (!$messageFormat) {
106
            // This should actually never happen, because the reader would throw an exception before here
107
            throw new Exception('Unknown XML format');
108
        }
109
110
        $expected = [
111 13
            V02::class,
112
            V04::class,
113
        ];
114
115 13
        if (!in_array(get_class($messageFormat), $expected, true)) {
116 1
            throw new Exception('The format CAMT 054 is expected, but instead we got: ' . $messageFormat->getMsgId());
117
        }
118 12
    }
119
120 11
    private function importTransaction(Entry $entry): void
121
    {
122 11
        $nativeDate = $entry->getValueDate();
123 11
        $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

123
        $date = Chronos::instance(/** @scrutinizer ignore-type */ $nativeDate);
Loading history...
124
125 11
        $transaction = new Transaction();
126 11
        $transaction->setName('Versement BVR');
127 11
        $transaction->setTransactionDate($date);
128
129 11
        $internalRemarks = [];
130 11
        foreach ($entry->getTransactionDetails() as $detail) {
131 11
            $internalRemarks[] = $this->importTransactionLine($transaction, $detail);
132
        }
133 9
        $transaction->setInternalRemarks(implode(PHP_EOL . PHP_EOL, $internalRemarks));
134
135
        // Don't persist transaction that may not have any lines
136 9
        $transactionLines = $transaction->getTransactionLines();
137 9
        if ($transactionLines->count()) {
138
            // Use same owner for line and transaction
139 8
            $transaction->setOwner($transactionLines->first()->getOwner());
140
141 8
            _em()->persist($transaction);
142 8
            $this->transactions[] = $transaction;
143
        }
144 9
    }
145
146 11
    private function importTransactionLine(Transaction $transaction, EntryTransactionDetail $detail): string
147
    {
148 11
        $importedId = $this->getImportedId($detail);
149 10
        $transactionDate = $transaction->getTransactionDate();
150 10
        if ($this->transactionLineRepository->importedExists($importedId, $transactionDate)) {
151 3
            return '';
152
        }
153
154 9
        $referenceNumber = $detail->getRemittanceInformation()->getStructuredBlock()->getCreditorReferenceInformation()->getRef();
155 9
        $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

155
        $user = $this->loadUser(/** @scrutinizer ignore-type */ $referenceNumber);
Loading history...
156 8
        $userAccount = $this->accountRepository->getOrCreate($user);
157 8
        $remarks = $this->getRemarks($detail, $referenceNumber);
158 8
        $amount = $detail->getAmount();
159
160 8
        $line = new TransactionLine();
161 8
        $line->setTransaction($transaction);
162 8
        $line->setOwner($user);
163 8
        $line->setName('Versement BVR');
164 8
        $line->setTransactionDate($transactionDate);
165 8
        $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

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

240
        /** @scrutinizer ignore-call */ 
241
        $account = $this->accountRepository->findOneByIban($iban);
Loading history...
241
242 12
        if (!$account) {
243 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.');
244
        }
245
246 11
        return $account;
247
    }
248
249 9
    private function loadUser(string $referenceNumber): User
250
    {
251 9
        $userId = (int) Bvr::extractCustomId($referenceNumber);
252 9
        $user = $this->userRepository->getOneById($userId);
253
254 9
        if (!$user) {
255 1
            throw new Exception('Could not find a matching user for reference number `' . $referenceNumber . '` and user ID `' . $userId . '`.');
256
        }
257
258 8
        return $user;
259
    }
260
261
    /**
262
     * This must return a non-empty universally unique identifier for one detail
263
     */
264 11
    private function getImportedId(EntryTransactionDetail $detail): string
265
    {
266 11
        $reference = $detail->getReference();
267
268 11
        $endToEndId = $reference->getEndToEndId();
269 11
        if (!$endToEndId || $endToEndId === 'NOTPROVIDED') {
270 10
            $endToEndId = $reference->getAccountServicerReference();
271
        }
272
273 11
        if (!$endToEndId) {
274 1
            $endToEndId = $reference->getMessageId();
275
        }
276
277 11
        if (!$endToEndId) {
278 1
            throw new Exception('Cannot import a transaction without unique universal identifier (<EndToEndId>, <AcctSvcrRef> or <MsgId>).');
279
        }
280
281 10
        return $endToEndId;
282
    }
283
}
284