Failed Conditions
Push — master ( a427b2...509ea4 )
by Adrien
16:44
created

Importer   A

Complexity

Total Complexity 33

Size/Duplication

Total Lines 248
Duplicated Lines 0 %

Test Coverage

Coverage 95.87%

Importance

Changes 2
Bugs 0 Features 0
Metric Value
wmc 33
eloc 121
dl 0
loc 248
rs 9.76
c 2
b 0
f 0
ccs 116
cts 121
cp 0.9587
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 \Genkgo\Camt\DTO\Message $message;
37
38
    /**
39
     * @var Transaction[]
40
     */
41
    private array $transactions = [];
42
43
    private \Application\Model\Account $bankAccount;
44
45
    private readonly \Application\Repository\AccountRepository $accountRepository;
46
47
    private readonly \Application\Repository\UserRepository $userRepository;
48
49
    private readonly TransactionLineRepository $transactionLineRepository;
0 ignored issues
show
Bug introduced by
A parse error occurred: Syntax error, unexpected T_STRING, expecting T_VARIABLE on line 49 at column 21
Loading history...
50
51 15
    public function __construct()
52
    {
53 15
        $this->accountRepository = _em()->getRepository(Account::class);
54 15
        $this->userRepository = _em()->getRepository(User::class);
55 15
        $this->transactionLineRepository = _em()->getRepository(TransactionLine::class);
56
    }
57
58
    /**
59
     * Import all transactions from a CAMT file.
60
     *
61
     * @return Transaction[]
62
     */
63 15
    public function import(string $file): array
64
    {
65 15
        $this->transactions = [];
66 15
        $reader = new Reader(Config::getDefault());
67
68
        try {
69 15
            $this->message = $reader->readFile($file);
70 2
        } catch (ReaderException $exception) {
71 2
            throw new Exception($exception->getMessage(), 0, $exception);
72
        }
73
74 13
        $this->validateFormat($reader);
75
76 12
        $this->userRepository->getAclFilter()->runWithoutAcl(function (): void {
77 12
            $records = $this->message->getRecords();
78 12
            foreach ($records as $record) {
79 12
                $this->bankAccount = $this->loadAccount($record);
80
81 11
                foreach ($record->getEntries() as $entry) {
82 11
                    $this->importTransaction($entry);
83
                }
84
            }
85
        });
86
87 9
        return $this->transactions;
88
    }
89
90 13
    private function validateFormat(Reader $reader): void
91
    {
92 13
        $messageFormat = $reader->getMessageFormat();
93 13
        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 13
        $expected = [
99
            V02::class,
100
            V04::class,
101
        ];
102
103 13
        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 11
    private function importTransaction(Entry $entry): void
109
    {
110 11
        $nativeDate = $entry->getValueDate();
111 11
        $date = Chronos::instance($nativeDate);
112
113 11
        $transaction = new Transaction();
114 11
        $transaction->setName('Versement BVR');
115 11
        $transaction->setTransactionDate($date);
116
117 11
        $internalRemarks = [];
118 11
        foreach ($entry->getTransactionDetails() as $detail) {
119 11
            $internalRemarks[] = $this->importTransactionLine($transaction, $detail);
120
        }
121 9
        $transaction->setInternalRemarks(implode(PHP_EOL . PHP_EOL, $internalRemarks));
122
123
        // Don't persist transaction that may not have any lines
124 9
        $transactionLines = $transaction->getTransactionLines();
125 9
        if ($transactionLines->count()) {
126
            // Use same owner for line and transaction
127 8
            $transaction->setOwner($transactionLines->first()->getOwner());
128
129 8
            _em()->persist($transaction);
130 8
            $this->transactions[] = $transaction;
131
        }
132
    }
133
134 11
    private function importTransactionLine(Transaction $transaction, EntryTransactionDetail $detail): string
135
    {
136 11
        $importedId = $this->getImportedId($detail);
137 10
        $transactionDate = $transaction->getTransactionDate();
138 10
        if ($this->transactionLineRepository->importedExists($importedId, $transactionDate)) {
139 3
            return '';
140
        }
141
142 9
        $referenceNumber = $detail->getRemittanceInformation()->getStructuredBlock()->getCreditorReferenceInformation()->getRef();
143 9
        $user = $this->loadUser($referenceNumber);
144 8
        $userAccount = $this->accountRepository->getOrCreate($user);
145 8
        $remarks = $this->getRemarks($detail, $referenceNumber);
146 8
        $amount = $detail->getAmount();
147
148 8
        $line = new TransactionLine();
149 8
        $line->setTransaction($transaction);
150 8
        $line->setOwner($user);
151 8
        $line->setName('Versement BVR');
152 8
        $line->setTransactionDate($transactionDate);
153 8
        $line->setBalance($amount);
154 8
        $line->setCredit($userAccount);
155 8
        $line->setDebit($this->bankAccount);
156 8
        $line->setImportedId($importedId);
157
158 8
        _em()->persist($line);
159
160 8
        return $remarks;
161
    }
162
163 2
    private function partyToString(RelatedPartyTypeInterface $party): string
164
    {
165 2
        $parts = [];
166 2
        $parts[] = $this->partyLabel($party);
167 2
        $parts[] = $party->getName();
168
169 2
        $address = $party->getAddress();
170 2
        if ($address) {
171 2
            $parts[] = $this->addressToString($address);
172
        }
173
174 2
        return implode(PHP_EOL, $parts);
175
    }
176
177 2
    private function partyLabel(RelatedPartyTypeInterface $party): string
178
    {
179 2
        $class = $party::class;
180
181 2
        return match ($class) {
182
            \Genkgo\Camt\DTO\Recipient::class => 'Récipient',
183 2
            \Genkgo\Camt\DTO\Debtor::class => 'Débiteur',
184 2
            \Genkgo\Camt\DTO\Creditor::class => 'Créancier',
185
            \Genkgo\Camt\DTO\UltimateDebtor::class => 'Débiteur final',
186
            \Genkgo\Camt\DTO\UltimateCreditor::class => 'Créancier final',
187 2
            default => throw new Exception('Non supported related party type: ' . $class),
188
        };
189
    }
190
191 2
    private function addressToString(Address $a): string
192
    {
193 2
        $lines = [];
194 2
        $lines[] = trim($a->getStreetName() . ' ' . $a->getBuildingNumber());
195 2
        $lines[] = trim($a->getPostCode() . ' ' . $a->getTownName());
196 2
        $lines[] = $a->getCountry();
197 2
        $lines = array_merge($lines, $a->getAddressLines());
198
199 2
        $nonEmptyLines = array_filter($lines);
200
201 2
        return implode(PHP_EOL, $nonEmptyLines);
202
    }
203
204 8
    private function getRemarks(EntryTransactionDetail $detail, string $referenceNumber): string
205
    {
206 8
        $parts = [];
207 8
        $parts[] = 'Numéro de référence: ' . $referenceNumber;
208
209 8
        foreach ($detail->getRelatedParties() as $party) {
210 2
            $partyDetail = $party->getRelatedPartyType();
211 2
            $parts[] = $this->partyToString($partyDetail);
212
        }
213
214 8
        $remarks = implode(PHP_EOL . PHP_EOL, $parts);
215
216 8
        return $remarks;
217
    }
218
219 12
    private function loadAccount(Record $record): Account
220
    {
221 12
        $accountFromFile = $record->getAccount();
222 12
        $iban = $accountFromFile->getIdentification();
223 12
        $account = $this->accountRepository->findOneByIban($iban);
224
225 12
        if (!$account) {
226 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.');
227
        }
228
229 11
        return $account;
230
    }
231
232 9
    private function loadUser(string $referenceNumber): User
233
    {
234 9
        $userId = (int) Bvr::extractCustomId($referenceNumber);
235 9
        $user = $this->userRepository->getOneById($userId);
236
237 9
        if (!$user) {
238 1
            throw new Exception('Could not find a matching user for reference number `' . $referenceNumber . '` and user ID `' . $userId . '`.');
239
        }
240
241 8
        return $user;
242
    }
243
244
    /**
245
     * This must return a non-empty universally unique identifier for one detail.
246
     */
247 11
    private function getImportedId(EntryTransactionDetail $detail): string
248
    {
249 11
        $reference = $detail->getReference();
250
251 11
        $endToEndId = $reference->getEndToEndId();
252 11
        if (!$endToEndId || $endToEndId === 'NOTPROVIDED') {
253 10
            $endToEndId = $reference->getAccountServicerReference();
254
        }
255
256 11
        if (!$endToEndId) {
257 1
            $endToEndId = $reference->getMessageId();
258
        }
259
260 11
        if (!$endToEndId) {
261 1
            throw new Exception('Cannot import a transaction without unique universal identifier (<EndToEndId>, <AcctSvcrRef> or <MsgId>).');
262
        }
263
264 10
        return $endToEndId;
265
    }
266
}
267