Passed
Push — master ( 4e7aa8...dae217 )
by Adrien
35:16 queued 30:13
created

Importer   A

Complexity

Total Complexity 32

Size/Duplication

Total Lines 242
Duplicated Lines 0 %

Test Coverage

Coverage 95.73%

Importance

Changes 9
Bugs 0 Features 0
Metric Value
wmc 32
eloc 117
c 9
b 0
f 0
dl 0
loc 242
ccs 112
cts 117
cp 0.9573
rs 9.84

12 Methods

Rating   Name   Duplication   Size   Complexity  
A importTransactionLine() 0 22 1
A getEndToEndId() 0 18 5
A __construct() 0 5 1
A getRemarks() 0 13 2
A import() 0 25 4
A loadAccount() 0 11 2
A validateFormat() 0 15 3
A partyToString() 0 12 2
A addressToString() 0 11 1
A loadUser() 0 10 2
A importTransaction() 0 22 3
A partyLabel() 0 16 6
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 10
    public function __construct()
64
    {
65 10
        $this->accountRepository = _em()->getRepository(Account::class);
66 10
        $this->userRepository = _em()->getRepository(User::class);
67 10
        $this->transactionLineRepository = _em()->getRepository(TransactionLine::class);
68 10
    }
69
70
    /**
71
     * Import all transactions from a CAMT file
72
     *
73
     * @return Transaction[]
74
     */
75 10
    public function import(string $file): array
76
    {
77 10
        $this->transactions = [];
78 10
        $reader = new Reader(Config::getDefault());
79
80
        try {
81 10
            $this->message = $reader->readFile($file);
82 2
        } catch (ReaderException $exception) {
83 2
            throw new Exception($exception->getMessage(), 0, $exception);
84
        }
85
86 8
        $this->validateFormat($reader);
87
88 7
        $this->userRepository->getAclFilter()->runWithoutAcl(function (): void {
89 7
            $records = $this->message->getRecords();
90 7
            foreach ($records as $record) {
91 7
                $this->bankAccount = $this->loadAccount($record);
92
93 6
                foreach ($record->getEntries() as $entry) {
94 6
                    $this->importTransaction($entry);
95
                }
96
            }
97 7
        });
98
99 3
        return $this->transactions;
100
    }
101
102 8
    private function validateFormat(Reader $reader): void
103
    {
104 8
        $messageFormat = $reader->getMessageFormat();
105 8
        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 8
            V02::class,
112
            V04::class,
113
        ];
114
115 8
        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 7
    }
119
120 6
    private function importTransaction(Entry $entry): void
121
    {
122 6
        $nativeDate = $entry->getValueDate();
123 6
        $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 6
        $transaction = new Transaction();
126 6
        $transaction->setName('Versement BVR');
127 6
        $transaction->setTransactionDate($date);
128
129 6
        $internalRemarks = [];
130 6
        foreach ($entry->getTransactionDetails() as $detail) {
131 6
            $internalRemarks[] = $this->importTransactionLine($transaction, $detail);
132
133
            // Use same owner for line and transaction
134 3
            $transaction->setOwner($transaction->getTransactionLines()->first()->getOwner());
135
        }
136 3
        $transaction->setInternalRemarks(implode(PHP_EOL . PHP_EOL, $internalRemarks));
137
138
        // Don't persist transaction that may not have any lines
139 3
        if ($transaction->getTransactionLines()->count()) {
140 3
            _em()->persist($transaction);
141 3
            $this->transactions[] = $transaction;
142
        }
143 3
    }
144
145 6
    private function importTransactionLine(Transaction $transaction, EntryTransactionDetail $detail): string
146
    {
147 6
        $referenceNumber = $detail->getRemittanceInformation()->getStructuredBlock()->getCreditorReferenceInformation()->getRef();
148 6
        $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

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

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

234
        /** @scrutinizer ignore-call */ 
235
        $account = $this->accountRepository->findOneByIban($iban);
Loading history...
235
236 7
        if (!$account) {
237 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.');
238
        }
239
240 6
        return $account;
241
    }
242
243 6
    private function loadUser(string $referenceNumber): User
244
    {
245 6
        $userId = (int) Bvr::extractCustomId($referenceNumber);
246 6
        $user = $this->userRepository->getOneById($userId);
247
248 6
        if (!$user) {
249 1
            throw new Exception('Could not find a matching user for reference number `' . $referenceNumber . '`.');
250
        }
251
252 5
        return $user;
253
    }
254
255
    /**
256
     * This must return a non-empty universally unique identifier for one detail
257
     */
258 5
    private function getEndToEndId(EntryTransactionDetail $detail): string
259
    {
260 5
        $reference = $detail->getReference();
261
262 5
        $endToEndId = $reference->getEndToEndId();
263 5
        if (!$endToEndId || $endToEndId === 'NOTPROVIDED') {
264 5
            $endToEndId = $reference->getAccountServicerReference();
265
        }
266
267 5
        if (!$endToEndId) {
268 1
            throw new Exception('Cannot import a transaction without an end-to-end ID or an account servicer reference to store a universal identifier.');
269
        }
270
271 4
        if ($this->transactionLineRepository->importedIdExists($endToEndId)) {
272 1
            throw new Exception('It looks like this file was already imported. A transaction line with the following `importedId` was already imported once and cannot be imported again: ' . $endToEndId);
273
        }
274
275 3
        return $endToEndId;
276
    }
277
}
278