Ecodev /
my-ichtus
| 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
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 |