Passed
Push — master ( 3b5920...d72050 )
by Adrien
08:18
created

Importer::getEndToEndId()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 14
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 8
CRAP Score 4

Importance

Changes 0
Metric Value
cc 4
eloc 7
c 0
b 0
f 0
nc 4
nop 1
dl 0
loc 14
ccs 8
cts 8
cp 1
crap 4
rs 10
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\UserRepository;
13
use Cake\Chronos\Chronos;
14
use Ecodev\Felix\Api\Exception;
15
use Ecodev\Felix\Service\Bvr;
16
use Genkgo\Camt\Config;
17
use Genkgo\Camt\DTO\Address;
18
use Genkgo\Camt\DTO\Entry;
19
use Genkgo\Camt\DTO\EntryTransactionDetail;
20
use Genkgo\Camt\DTO\Message;
21
use Genkgo\Camt\DTO\Record;
22
use Genkgo\Camt\DTO\RelatedPartyTypeInterface;
23
use Genkgo\Camt\Reader;
24
25
/**
26
 * This service allows to import a CAMT file as Transaction and TransactionLine
27
 *
28
 * @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
29
 */
30
class Importer
31
{
32
    /**
33
     * @var Message
34
     */
35
    private $message;
36
37
    /**
38
     * @var Transaction[]
39
     */
40
    private $transactions = [];
41
42
    /**
43
     * @var Account
44
     */
45
    private $bankAccount;
46
47
    /**
48
     * @var AccountRepository
49
     */
50
    private $accountRepository;
51
52
    /**
53
     * @var UserRepository
54
     */
55
    private $userRepository;
56
57 7
    public function __construct()
58
    {
59 7
        $this->accountRepository = _em()->getRepository(Account::class);
60 7
        $this->userRepository = _em()->getRepository(User::class);
61 7
    }
62
63
    /**
64
     * Import all transactions from a CAMT file
65
     *
66
     * @return Transaction[]
67
     */
68 7
    public function import(string $file): array
69
    {
70 7
        $this->transactions = [];
71 7
        $reader = new Reader(Config::getDefault());
72 7
        $this->message = $reader->readFile($file);
73
74
        $this->userRepository->getAclFilter()->runWithoutAcl(function (): void {
75 6
            $records = $this->message->getRecords();
76 6
            foreach ($records as $record) {
77 6
                $this->bankAccount = $this->loadAccount($record);
78
79 5
                foreach ($record->getEntries() as $entry) {
80 5
                    $this->importTransaction($entry);
81
                }
82
            }
83 6
        });
84
85 3
        return $this->transactions;
86
    }
87
88 5
    private function importTransaction(Entry $entry): void
89
    {
90 5
        $nativeDate = $entry->getValueDate();
91 5
        $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

91
        $date = Chronos::instance(/** @scrutinizer ignore-type */ $nativeDate);
Loading history...
92
93 5
        $transaction = new Transaction();
94 5
        $transaction->setName('Versement BVR');
95 5
        $transaction->setTransactionDate($date);
96
97 5
        $internalRemarks = [];
98 5
        foreach ($entry->getTransactionDetails() as $detail) {
99 5
            $internalRemarks[] = $this->importTransactionLine($transaction, $detail);
100
101
            // Use same owner for line and transaction
102 3
            $transaction->setOwner($transaction->getTransactionLines()->first()->getOwner());
103
        }
104 3
        $transaction->setInternalRemarks(implode(PHP_EOL . PHP_EOL, $internalRemarks));
105
106
        // Don't persist transaction that may not have any lines
107 3
        if ($transaction->getTransactionLines()->count()) {
108 3
            _em()->persist($transaction);
109 3
            $this->transactions[] = $transaction;
110
        }
111 3
    }
112
113 5
    private function importTransactionLine(Transaction $transaction, EntryTransactionDetail $detail): string
114
    {
115 5
        $referenceNumber = $detail->getRemittanceInformation()->getStructuredBlock()->getCreditorReferenceInformation()->getRef();
116 5
        $user = $this->loadUser($referenceNumber);
117 4
        $userAccount = $this->accountRepository->getOrCreate($user);
118 4
        $remarks = $this->getRemarks($detail, $referenceNumber);
119 4
        $amount = $detail->getAmount();
120 4
        $endToEndId = $this->getEndToEndId($detail);
121
122 3
        $line = new TransactionLine();
123 3
        $line->setTransaction($transaction);
124 3
        $line->setOwner($user);
125 3
        $line->setName('Versement BVR');
126 3
        $line->setTransactionDate($transaction->getTransactionDate());
127 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

127
        $line->setBalance(/** @scrutinizer ignore-type */ $amount);
Loading history...
128 3
        $line->setCredit($userAccount);
129 3
        $line->setDebit($this->bankAccount);
130 3
        $line->setImportedId($endToEndId);
131
132 3
        _em()->persist($line);
133
134 3
        return $remarks;
135
    }
136
137 1
    private function partyToString(RelatedPartyTypeInterface $party): string
138
    {
139 1
        $parts = [];
140 1
        $parts[] = $this->partyLabel($party);
141 1
        $parts[] = $party->getName();
142
143 1
        $address = $party->getAddress();
144 1
        if ($address) {
145 1
            $parts[] = $this->addressToString($address);
146
        }
147
148 1
        return implode(PHP_EOL, $parts);
149
    }
150
151 1
    private function partyLabel(RelatedPartyTypeInterface $party): string
152
    {
153 1
        $class = get_class($party);
154 1
        switch ($class) {
155
            case \Genkgo\Camt\DTO\Recipient::class:
156
                return 'Récipient';
157
            case \Genkgo\Camt\DTO\Debtor::class:
158 1
                return 'Débiteur';
159
            case \Genkgo\Camt\DTO\Creditor::class:
160 1
                return 'Créancier';
161
            case \Genkgo\Camt\DTO\UltimateDebtor::class:
162
                return 'Débiteur final';
163
            case \Genkgo\Camt\DTO\UltimateCreditor::class:
164
                return 'Créancier final';
165
            default:
166
                throw new Exception('Non supported related party type: ' . $class);
167
        }
168
    }
169
170 1
    private function addressToString(Address $a): string
171
    {
172 1
        $lines = [];
173 1
        $lines[] = trim($a->getStreetName() . ' ' . $a->getBuildingNumber());
174 1
        $lines[] = trim($a->getPostCode() . ' ' . $a->getTownName());
175 1
        $lines[] = $a->getCountry();
176 1
        $lines = array_merge($lines, $a->getAddressLines());
177
178 1
        $nonEmptyLines = array_filter($lines);
179
180 1
        return implode(PHP_EOL, $nonEmptyLines);
181
    }
182
183 4
    private function getRemarks(EntryTransactionDetail $detail, string $referenceNumber): string
184
    {
185 4
        $parts = [];
186 4
        $parts[] = 'Numéro de référence: ' . $referenceNumber;
187
188 4
        foreach ($detail->getRelatedParties() as $party) {
189 1
            $partyDetail = $party->getRelatedPartyType();
190 1
            $parts[] = $this->partyToString($partyDetail);
191
        }
192
193 4
        $remarks = implode(PHP_EOL . PHP_EOL, $parts);
194
195 4
        return $remarks;
196
    }
197
198 6
    private function loadAccount(Record $record): Account
199
    {
200 6
        $accountFromFile = $record->getAccount();
201 6
        $iban = $accountFromFile->getIdentification();
202 6
        $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

202
        /** @scrutinizer ignore-call */ 
203
        $account = $this->accountRepository->findOneByIban($iban);
Loading history...
203
204 6
        if (!$account) {
205 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.');
206
        }
207
208 5
        return $account;
209
    }
210
211 5
    private function loadUser(string $referenceNumber): User
212
    {
213 5
        $userId = (int) Bvr::extractCustomId($referenceNumber);
214 5
        $user = $this->userRepository->getOneById($userId);
215
216 5
        if (!$user) {
217 1
            throw new Exception('Could not find a matching user for reference number `' . $referenceNumber . '`.');
218
        }
219
220 4
        return $user;
221
    }
222
223
    /**
224
     * This must return a non-empty universally unique identifier for one detail
225
     */
226 4
    private function getEndToEndId(EntryTransactionDetail $detail): string
227
    {
228 4
        $reference = $detail->getReference();
229
230 4
        $endToEndId = $reference->getEndToEndId();
231 4
        if (!$endToEndId || $endToEndId === 'NOTPROVIDED') {
232 4
            $endToEndId = $reference->getAccountServicerReference();
233
        }
234
235 4
        if (!$endToEndId) {
236 1
            throw new Exception('Cannot import a transaction without an end-to-end ID or an account servicer reference to store a universal identifier.');
237
        }
238
239 3
        return $endToEndId;
240
    }
241
}
242