From 4f3d28776c30826b44632f0186831a89ae0d5ad1 Mon Sep 17 00:00:00 2001 From: Martin Brettschneider Date: Thu, 30 Jul 2026 22:36:07 +0200 Subject: [PATCH 1/6] fix: make optional DomesticTransaction properties nullable toArray() on a valid minimal transaction (only mandatory fields set) threw "Typed property must not be accessed before initialization" for optional properties without setters called, crashing PaymentService::sendPayments(). Null values are already skipped by ExportXmlGenerator, so XML output is unchanged. Co-Authored-By: Claude Fable 5 --- .../Transaction/DomesticTransaction.php | 14 ++++----- .../Transaction/DomesticTransactionTest.phpt | 29 +++++++++++++++++++ 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/src/Entity/Transaction/DomesticTransaction.php b/src/Entity/Transaction/DomesticTransaction.php index b71c948..bda6a90 100644 --- a/src/Entity/Transaction/DomesticTransaction.php +++ b/src/Entity/Transaction/DomesticTransaction.php @@ -24,21 +24,21 @@ final class DomesticTransaction extends Transaction // Příkaz k inkasu public const PAYMENT_TYPE_COLLECTION = 431022; - private int $paymentType; + private ?int $paymentType = null; private string $bankCode; - private string $ks; + private ?string $ks = null; - private string $vs; + private ?string $vs = null; - private string $ss; + private ?string $ss = null; - private string $messageForRecipient; + private ?string $messageForRecipient = null; - private string $comment; + private ?string $comment = null; - private int $paymentReason; + private ?int $paymentReason = null; public function setPaymentType(int $paymentType): void { diff --git a/tests/cases/Entity/Transaction/DomesticTransactionTest.phpt b/tests/cases/Entity/Transaction/DomesticTransactionTest.phpt index 8c0efd9..0165b32 100644 --- a/tests/cases/Entity/Transaction/DomesticTransactionTest.phpt +++ b/tests/cases/Entity/Transaction/DomesticTransactionTest.phpt @@ -75,3 +75,32 @@ Toolkit::test(function (): void { Assert::true($t->isValid()); }); + +// Valid minimal transaction can be exported without optional setters +Toolkit::test(function (): void { + $t = new DomesticTransaction(); + $t->setAccountFrom('111222333444'); + $t->setAmount(222.22); + $t->setAccountTo('222444666'); + $t->setBankCode('0300'); + $t->setDate(new DateTimeImmutable('2026-07-30')); + + Assert::true($t->isValid()); + Assert::noError(function () use ($t): void { + $t->toArray(); + }); + + $array = $t->toArray(); + Assert::same('111222333444', $array['accountFrom']); + Assert::same(222.22, $array['amount']); + Assert::same('222444666', $array['accountTo']); + Assert::same('0300', $array['bankCode']); + Assert::same('2026-07-30', $array['date']); + Assert::null($array['ks']); + Assert::null($array['vs']); + Assert::null($array['ss']); + Assert::null($array['messageForRecipient']); + Assert::null($array['comment']); + Assert::null($array['paymentReason']); + Assert::null($array['paymentType']); +}); From 39bb702842e6a3f2aa26c3ac405d4f70116cb595 Mon Sep 17 00:00:00 2001 From: Martin Brettschneider Date: Fri, 31 Jul 2026 08:04:06 +0200 Subject: [PATCH 2/6] refactor(transaction): unify property validation in base class MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract assertMaxLength/assertExactLength/assertLengthBetween helpers and move setPaymentReason/setPaymentType up — shared by all transaction types, subclasses keep only their constants. --- .../Transaction/DomesticTransaction.php | 48 ++------------ src/Entity/Transaction/Transaction.php | 62 +++++++++++++++++-- 2 files changed, 62 insertions(+), 48 deletions(-) diff --git a/src/Entity/Transaction/DomesticTransaction.php b/src/Entity/Transaction/DomesticTransaction.php index bda6a90..f59d709 100644 --- a/src/Entity/Transaction/DomesticTransaction.php +++ b/src/Entity/Transaction/DomesticTransaction.php @@ -2,8 +2,6 @@ namespace Contributte\Fio\Entity\Transaction; -use Contributte\Fio\Exceptions\InvalidPropertyException; - /** * DomesticTransaction */ @@ -24,8 +22,6 @@ final class DomesticTransaction extends Transaction // Příkaz k inkasu public const PAYMENT_TYPE_COLLECTION = 431022; - private ?int $paymentType = null; - private string $bankCode; private ?string $ks = null; @@ -38,80 +34,48 @@ final class DomesticTransaction extends Transaction private ?string $comment = null; - private ?int $paymentReason = null; - - public function setPaymentType(int $paymentType): void - { - if (strlen((string) $paymentType) !== 6) { - throw new InvalidPropertyException('$paymentType has to be 6 digits long.'); - } - - $this->paymentType = $paymentType; - } - public function setBankCode(string $bankCode): void { - if (strlen($bankCode) !== 4) { - throw new InvalidPropertyException('$bankCode must be 4 digits long.'); - } + $this->assertExactLength($bankCode, 4, 'bankCode'); $this->bankCode = $bankCode; } public function setKs(string $ks): void { - if (strlen($ks) > 4) { - throw new InvalidPropertyException('$ks can be maximum of 4 digits long.'); - } + $this->assertMaxLength($ks, 4, 'ks'); $this->ks = $ks; } public function setVs(string $vs): void { - if (strlen($vs) > 10) { - throw new InvalidPropertyException('$vs can be maximum of 10 digits long.'); - } + $this->assertMaxLength($vs, 10, 'vs'); $this->vs = $vs; } public function setSs(string $ss): void { - if (strlen($ss) > 10) { - throw new InvalidPropertyException('$ss can be maximum of 10 digits long.'); - } + $this->assertMaxLength($ss, 10, 'ss'); $this->ss = $ss; } public function setMessageForRecipient(string $messageForRecipient): void { - if (strlen($messageForRecipient) > 140) { - throw new InvalidPropertyException('$messageForRecipient can be maximum of 140 characters long.'); - } + $this->assertMaxLength($messageForRecipient, 140, 'messageForRecipient'); $this->messageForRecipient = $messageForRecipient; } public function setComment(string $comment): void { - if (strlen($comment) > 255) { - throw new InvalidPropertyException('$comment can be maximum of 255 characters long.'); - } + $this->assertMaxLength($comment, 255, 'comment'); $this->comment = $comment; } - public function setPaymentReason(int $paymentReason): void - { - if (strlen((string) $paymentReason) !== 3) { - throw new InvalidPropertyException('$paymentReason must be 3 digits long.'); - } - - $this->paymentReason = $paymentReason; - } - /** * @return mixed[] */ diff --git a/src/Entity/Transaction/Transaction.php b/src/Entity/Transaction/Transaction.php index 11af48f..30b52a7 100644 --- a/src/Entity/Transaction/Transaction.php +++ b/src/Entity/Transaction/Transaction.php @@ -27,6 +27,10 @@ abstract class Transaction protected string $date; + protected ?int $paymentReason = null; + + protected ?int $paymentType = null; + /** * Checks if all mandatory data are set */ @@ -34,18 +38,14 @@ abstract public function isValid(): bool; public function setAccountFrom(string $accountFrom): void { - if (strlen($accountFrom) > 16) { - throw new InvalidPropertyException('Maximum accountFrom length is 16.'); - } + $this->assertMaxLength($accountFrom, 16, 'accountFrom'); $this->accountFrom = $accountFrom; } public function setCurrency(string $currency): void { - if (strlen($currency) !== 3) { - throw new InvalidPropertyException('Currency code length must be 3.'); - } + $this->assertExactLength($currency, 3, 'currency'); $this->currency = $currency; } @@ -73,6 +73,26 @@ public function setDate(DateTimeInterface $date): void $this->date = $date->format('Y-m-d'); } + /** + * Payment reason code, see FIO API docs chapter 6.3.4 + */ + public function setPaymentReason(int $paymentReason): void + { + $this->assertExactLength((string) $paymentReason, 3, 'paymentReason'); + + $this->paymentReason = $paymentReason; + } + + /** + * Allowed values are the PAYMENT_TYPE_* constants of the concrete transaction + */ + public function setPaymentType(int $paymentType): void + { + $this->assertExactLength((string) $paymentType, 6, 'paymentType'); + + $this->paymentType = $paymentType; + } + /** * @return mixed[] */ @@ -86,4 +106,34 @@ public function toArray(): array ]; } + /** + * @throws InvalidPropertyException + */ + protected function assertMaxLength(string $value, int $maxLength, string $property): void + { + if (strlen($value) > $maxLength) { + throw new InvalidPropertyException(sprintf('$%s can be maximum of %d characters long.', $property, $maxLength)); + } + } + + /** + * @throws InvalidPropertyException + */ + protected function assertExactLength(string $value, int $length, string $property): void + { + if (strlen($value) !== $length) { + throw new InvalidPropertyException(sprintf('$%s must be exactly %d characters long.', $property, $length)); + } + } + + /** + * @throws InvalidPropertyException + */ + protected function assertLengthBetween(string $value, int $minLength, int $maxLength, string $property): void + { + if (strlen($value) < $minLength || strlen($value) > $maxLength) { + throw new InvalidPropertyException(sprintf('$%s must be between %d and %d characters long.', $property, $minLength, $maxLength)); + } + } + } From b81f8285f3c350c0529b3cca7a6cac39edd1caa3 Mon Sep 17 00:00:00 2001 From: Martin Brettschneider Date: Fri, 31 Jul 2026 08:04:06 +0200 Subject: [PATCH 3/6] feat: add EuroTransaction for euro payments Maps to T2Transaction element (FIO API docs 6.3.2). AbroadTransaction base holds fields shared with foreign payments (bic, beneficiary, remittance info). Recipient account validated as IBAN. --- src/Entity/Transaction/AbroadTransaction.php | 98 ++++++++++++++ src/Entity/Transaction/EuroTransaction.php | 100 ++++++++++++++ .../Transaction/EuroTransactionTest.phpt | 124 ++++++++++++++++++ 3 files changed, 322 insertions(+) create mode 100644 src/Entity/Transaction/AbroadTransaction.php create mode 100644 src/Entity/Transaction/EuroTransaction.php create mode 100644 tests/cases/Entity/Transaction/EuroTransactionTest.phpt diff --git a/src/Entity/Transaction/AbroadTransaction.php b/src/Entity/Transaction/AbroadTransaction.php new file mode 100644 index 0000000..ced141f --- /dev/null +++ b/src/Entity/Transaction/AbroadTransaction.php @@ -0,0 +1,98 @@ +assertMaxLength($benefName, 35, 'benefName'); + + $this->benefName = $benefName; + } + + /** + * ISO 9362, pad 8-char BIC with XXX + */ + public function setBic(string $bic): void + { + $this->assertExactLength($bic, 11, 'bic'); + + $this->bic = $bic; + } + + public function setComment(string $comment): void + { + $this->assertMaxLength($comment, 140, 'comment'); + + $this->comment = $comment; + } + + public function setBenefStreet(string $benefStreet): void + { + $this->assertMaxLength($benefStreet, 35, 'benefStreet'); + + $this->benefStreet = $benefStreet; + } + + public function setBenefCity(string $benefCity): void + { + $this->assertMaxLength($benefCity, 35, 'benefCity'); + + $this->benefCity = $benefCity; + } + + /** + * Country code of the account owner, see FIO API docs chapter 6.3.2 + */ + public function setBenefCountry(string $benefCountry): void + { + $this->assertLengthBetween($benefCountry, 2, 3, 'benefCountry'); + + $this->benefCountry = $benefCountry; + } + + public function setRemittanceInfo1(string $remittanceInfo): void + { + $this->assertMaxLength($remittanceInfo, 35, 'remittanceInfo'); + + $this->remittanceInfo1 = $remittanceInfo; + } + + public function setRemittanceInfo2(string $remittanceInfo): void + { + $this->assertMaxLength($remittanceInfo, 35, 'remittanceInfo'); + + $this->remittanceInfo2 = $remittanceInfo; + } + + public function setRemittanceInfo3(string $remittanceInfo): void + { + $this->assertMaxLength($remittanceInfo, 35, 'remittanceInfo'); + + $this->remittanceInfo3 = $remittanceInfo; + } + +} diff --git a/src/Entity/Transaction/EuroTransaction.php b/src/Entity/Transaction/EuroTransaction.php new file mode 100644 index 0000000..883478c --- /dev/null +++ b/src/Entity/Transaction/EuroTransaction.php @@ -0,0 +1,100 @@ +accountTo = $accountTo; + } + + public function setKs(string $ks): void + { + $this->assertMaxLength($ks, 4, 'ks'); + + $this->ks = $ks; + } + + public function setVs(string $vs): void + { + $this->assertMaxLength($vs, 10, 'vs'); + + $this->vs = $vs; + } + + public function setSs(string $ss): void + { + $this->assertMaxLength($ss, 10, 'ss'); + + $this->ss = $ss; + } + + /** + * @return mixed[] + */ + public function toArray(): array + { + return array_merge(parent::toArray(), [ + 'ks' => $this->ks, + 'vs' => $this->vs, + 'ss' => $this->ss, + 'bic' => $this->bic, + 'date' => $this->date, + 'comment' => $this->comment, + 'benefName' => $this->benefName, + 'benefStreet' => $this->benefStreet, + 'benefCity' => $this->benefCity, + 'benefCountry' => $this->benefCountry, + 'remittanceInfo1' => $this->remittanceInfo1, + 'remittanceInfo2' => $this->remittanceInfo2, + 'remittanceInfo3' => $this->remittanceInfo3, + 'paymentReason' => $this->paymentReason, + 'paymentType' => $this->paymentType, + ]); + } + + public function isValid(): bool + { + return isset( + $this->accountFrom, + $this->amount, + $this->accountTo, + $this->date, + $this->benefName + ); + } + +} diff --git a/tests/cases/Entity/Transaction/EuroTransactionTest.phpt b/tests/cases/Entity/Transaction/EuroTransactionTest.phpt new file mode 100644 index 0000000..a29c2c8 --- /dev/null +++ b/tests/cases/Entity/Transaction/EuroTransactionTest.phpt @@ -0,0 +1,124 @@ +setAccountFrom('1234562'); + $t->setAmount(100.00); + $t->setAccountTo('AT611904300234573201'); + $t->setDate(new DateTimeImmutable('2013-04-25')); + $t->setBenefName('Hans Gruber'); + + return $t; +} + +// Valid minimal transaction, EUR is default currency +Toolkit::test(function (): void { + $t = createValidTransaction(); + + Assert::true($t->isValid()); + Assert::same('EUR', $t->toArray()['currency']); +}); + +// Missing benefName +Toolkit::test(function (): void { + $t = new EuroTransaction(); + $t->setAccountFrom('1234562'); + $t->setAmount(100.00); + $t->setAccountTo('AT611904300234573201'); + $t->setDate(new DateTimeImmutable('2013-04-25')); + + Assert::false($t->isValid()); +}); + +// Missing accountTo +Toolkit::test(function (): void { + $t = new EuroTransaction(); + $t->setAccountFrom('1234562'); + $t->setAmount(100.00); + $t->setDate(new DateTimeImmutable('2013-04-25')); + $t->setBenefName('Hans Gruber'); + + Assert::false($t->isValid()); +}); + +// AccountTo must be IBAN +Toolkit::test(function (): void { + $t = new EuroTransaction(); + + Assert::throws(function () use ($t): void { + $t->setAccountTo('2212-2000000699'); + }, InvalidPropertyException::class); +}); + +// Bic must be 11 chars +Toolkit::test(function (): void { + $t = new EuroTransaction(); + + Assert::throws(function () use ($t): void { + $t->setBic('ABAGATWW'); + }, InvalidPropertyException::class); +}); + +// BenefName max 35 chars +Toolkit::test(function (): void { + $t = new EuroTransaction(); + + Assert::throws(function () use ($t): void { + $t->setBenefName(str_repeat('a', 36)); + }, InvalidPropertyException::class); +}); + +// Comment max 140 chars +Toolkit::test(function (): void { + $t = new EuroTransaction(); + + Assert::throws(function () use ($t): void { + $t->setComment(str_repeat('a', 141)); + }, InvalidPropertyException::class); +}); + +// Full transaction to XML, element named T2Transaction +Toolkit::test(function (): void { + $t = createValidTransaction(); + $t->setKs('0558'); + $t->setVs('1234567890'); + $t->setSs('1234567890'); + $t->setBic('ABAGATWWXXX'); + $t->setComment('Erste Zahlung'); + $t->setBenefStreet('Gugitzgasse 2'); + $t->setBenefCity('Wien'); + $t->setBenefCountry('AT'); + $t->setRemittanceInfo1('info1'); + $t->setPaymentType(EuroTransaction::PAYMENT_TYPE_STANDARD); + + $list = new TransactionList(); + $list->addTransaction($t); + $xml = $list->toXml(); + + Assert::contains('', $xml); + Assert::contains('1234562', $xml); + Assert::contains('EUR', $xml); + Assert::contains('100', $xml); + Assert::contains('AT611904300234573201', $xml); + Assert::contains('ABAGATWWXXX', $xml); + Assert::contains('2013-04-25', $xml); + Assert::contains('Hans Gruber', $xml); + Assert::contains('AT', $xml); + Assert::contains('431008', $xml); + + // Optional fields left unset are omitted + Assert::notContains('', $xml); + Assert::notContains('', $xml); +}); From ef6259e2bca51db6e288bd59d1d41aef4fb32b69 Mon Sep 17 00:00:00 2001 From: Martin Brettschneider Date: Fri, 31 Jul 2026 08:04:20 +0200 Subject: [PATCH 4/6] feat: add ForeignTransaction for non-SEPA foreign payments FIO API docs 6.3.3. Stricter mandatory set than euro payments (bic, full beneficiary address, remittanceInfo1, detailsOfCharges, paymentReason). Recipient account is any account number, not necessarily IBAN. --- src/Entity/Transaction/ForeignTransaction.php | 97 ++++++++++++++++++ .../Transaction/ForeignTransactionTest.phpt | 99 +++++++++++++++++++ 2 files changed, 196 insertions(+) create mode 100644 src/Entity/Transaction/ForeignTransaction.php create mode 100644 tests/cases/Entity/Transaction/ForeignTransactionTest.phpt diff --git a/src/Entity/Transaction/ForeignTransaction.php b/src/Entity/Transaction/ForeignTransaction.php new file mode 100644 index 0000000..90008d5 --- /dev/null +++ b/src/Entity/Transaction/ForeignTransaction.php @@ -0,0 +1,97 @@ +accountTo = $accountTo; + } + + /** + * Fees allocation, use one of the CHARGES_* constants + */ + public function setDetailsOfCharges(int $detailsOfCharges): void + { + $this->assertExactLength((string) $detailsOfCharges, 6, 'detailsOfCharges'); + + $this->detailsOfCharges = $detailsOfCharges; + } + + public function setRemittanceInfo4(string $remittanceInfo): void + { + $this->assertMaxLength($remittanceInfo, 35, 'remittanceInfo'); + + $this->remittanceInfo4 = $remittanceInfo; + } + + /** + * @return mixed[] + */ + public function toArray(): array + { + return array_merge(parent::toArray(), [ + 'bic' => $this->bic, + 'date' => $this->date, + 'comment' => $this->comment, + 'benefName' => $this->benefName, + 'benefStreet' => $this->benefStreet, + 'benefCity' => $this->benefCity, + 'benefCountry' => $this->benefCountry, + 'remittanceInfo1' => $this->remittanceInfo1, + 'remittanceInfo2' => $this->remittanceInfo2, + 'remittanceInfo3' => $this->remittanceInfo3, + 'remittanceInfo4' => $this->remittanceInfo4, + 'detailsOfCharges' => $this->detailsOfCharges, + 'paymentReason' => $this->paymentReason, + ]); + } + + public function isValid(): bool + { + return isset( + $this->accountFrom, + $this->amount, + $this->accountTo, + $this->bic, + $this->date, + $this->benefName, + $this->benefStreet, + $this->benefCity, + $this->benefCountry, + $this->remittanceInfo1, + $this->detailsOfCharges, + $this->paymentReason + ); + } + +} diff --git a/tests/cases/Entity/Transaction/ForeignTransactionTest.phpt b/tests/cases/Entity/Transaction/ForeignTransactionTest.phpt new file mode 100644 index 0000000..3d64018 --- /dev/null +++ b/tests/cases/Entity/Transaction/ForeignTransactionTest.phpt @@ -0,0 +1,99 @@ +setAccountFrom('1234562'); + $t->setCurrency('USD'); + $t->setAmount(100.00); + $t->setAccountTo('PK36SCBL0000001123456702'); + $t->setBic('ALFHPKKAXXX'); + $t->setDate(new DateTimeImmutable('2013-04-25')); + $t->setBenefName('Amir Khan'); + $t->setBenefStreet('Nishtar Rd 13'); + $t->setBenefCity('Karachi'); + $t->setBenefCountry('PK'); + $t->setRemittanceInfo1('Payment for hotel 032013'); + $t->setDetailsOfCharges(ForeignTransaction::CHARGES_BEN); + $t->setPaymentReason(348); + + return $t; +} + +// Valid transaction with all mandatory fields +Toolkit::test(function (): void { + Assert::true(createValidForeignTransaction()->isValid()); +}); + +// Every mandatory field missing invalidates the transaction +Toolkit::test(function (): void { + $t = new ForeignTransaction(); + $t->setAccountFrom('1234562'); + $t->setAmount(100.00); + $t->setAccountTo('PK36SCBL0000001123456702'); + $t->setDate(new DateTimeImmutable('2013-04-25')); + $t->setBenefName('Amir Khan'); + + // bic, benefStreet, benefCity, benefCountry, remittanceInfo1, detailsOfCharges, paymentReason missing + Assert::false($t->isValid()); +}); + +// AccountTo allows non-IBAN account numbers, max 34 alphanumeric +Toolkit::test(function (): void { + $t = new ForeignTransaction(); + $t->setAccountTo('1234567890'); + + Assert::throws(function () use ($t): void { + $t->setAccountTo(str_repeat('1', 35)); + }, InvalidPropertyException::class); + + Assert::throws(function () use ($t): void { + $t->setAccountTo('2212-2000000699'); + }, InvalidPropertyException::class); +}); + +// DetailsOfCharges must be 6 digits +Toolkit::test(function (): void { + $t = new ForeignTransaction(); + + Assert::throws(function () use ($t): void { + $t->setDetailsOfCharges(4705); + }, InvalidPropertyException::class); +}); + +// Full transaction to XML, element named ForeignTransaction +Toolkit::test(function (): void { + $t = createValidForeignTransaction(); + $t->setComment('Payment a0315'); + $t->setRemittanceInfo4('info4'); + + $list = new TransactionList(); + $list->addTransaction($t); + $xml = $list->toXml(); + + Assert::contains('', $xml); + Assert::contains('USD', $xml); + Assert::contains('PK36SCBL0000001123456702', $xml); + Assert::contains('ALFHPKKAXXX', $xml); + Assert::contains('Amir Khan', $xml); + Assert::contains('PK', $xml); + Assert::contains('Payment for hotel 032013', $xml); + Assert::contains('info4', $xml); + Assert::contains('470502', $xml); + Assert::contains('348', $xml); + + // Optional fields left unset are omitted + Assert::notContains('', $xml); + Assert::notContains('', $xml); +}); From 0b40ebceb25bf83fd7a61acc40ce30ffbfea7644 Mon Sep 17 00:00:00 2001 From: Martin Brettschneider Date: Fri, 31 Jul 2026 08:04:20 +0200 Subject: [PATCH 5/6] docs: describe supported transaction types Mandatory fields per type and required batch order (domestic, euro, foreign). --- .docs/README.md | 34 ++++++++++++++++++++++++++++++---- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/.docs/README.md b/.docs/README.md index c054fd7..0a2bfbb 100644 --- a/.docs/README.md +++ b/.docs/README.md @@ -31,16 +31,42 @@ fio: ## Usage -As this package is under development we haven't included downloading payments from bank yet. You can use our package for simply sending domestic payments to bank. You can send multiple payments at once but you can query bank api only once in 30 seconds (if you ping servers in shorter intervals it will return error). This package only supports domestic transactions but it is very easy to implement other types of payments. You only need to extend `Contributte\Fio\Entity\Transaction\Transaction` class and use it same way as Domestic Transaction class which has these mandatory properties: +As this package is under development we haven't included downloading payments from bank yet. You can use our package for simply sending payments to bank. You can send multiple payments at once but you can query bank api only once in 30 seconds (if you ping servers in shorter intervals it will return error). This package supports domestic transactions (`DomesticTransaction`), euro payments (`EuroTransaction`) and foreign payments (`ForeignTransaction`). -* Sender account (automatically supplier from config) -* Currency +`DomesticTransaction` mandatory properties: + +* Sender account (automatically supplied from config) +* Currency (default CZK) * Amount * Recipient account * Bank code * Date -For more properties check the class itself. +`EuroTransaction` mandatory properties: + +* Sender account (automatically supplied from config) +* Currency (default EUR) +* Amount +* Recipient account (IBAN) +* Date +* Beneficiary name (`setBenefName()`) + +`ForeignTransaction` mandatory properties: + +* Sender account (automatically supplied from config) +* Currency (account currency, default CZK) +* Amount +* Recipient account (account number or IBAN) +* BIC +* Date +* Beneficiary name, street, city and country +* Remittance info 1 (`setRemittanceInfo1()`) +* Details of charges (`setDetailsOfCharges()`, `CHARGES_OUR`/`CHARGES_BEN`/`CHARGES_SHA`) +* Payment reason (`setPaymentReason()`, see FIO API docs chapter 6.3.4) + +When mixing transaction types in one batch, the bank requires this order: domestic payments, euro payments, foreign payments. + +For more properties check the classes themselves. From `FioManager` you get `PaymentService` which has two methods: From 1c9b42ebd6edb5420d83fdf817d325cf6eb4d6a5 Mon Sep 17 00:00:00 2001 From: Martin Brettschneider Date: Fri, 31 Jul 2026 08:10:44 +0200 Subject: [PATCH 6/6] feat: sort export batch into bank-required type order Bank rejects import unless types are ordered domestic, euro, foreign (FIO API docs 6.3). Stable sort keyed by XML element NAME, so custom Transaction subclasses order correctly too. Iterator keeps insertion order, only the export is sorted. --- .docs/README.md | 2 +- src/Entity/Transaction/TransactionList.php | 18 ++- .../Transaction/TransactionListTest.phpt | 131 ++++++++++++++++++ 3 files changed, 148 insertions(+), 3 deletions(-) create mode 100644 tests/cases/Entity/Transaction/TransactionListTest.phpt diff --git a/.docs/README.md b/.docs/README.md index 0a2bfbb..c48f18a 100644 --- a/.docs/README.md +++ b/.docs/README.md @@ -64,7 +64,7 @@ As this package is under development we haven't included downloading payments fr * Details of charges (`setDetailsOfCharges()`, `CHARGES_OUR`/`CHARGES_BEN`/`CHARGES_SHA`) * Payment reason (`setPaymentReason()`, see FIO API docs chapter 6.3.4) -When mixing transaction types in one batch, the bank requires this order: domestic payments, euro payments, foreign payments. +The bank requires transaction types in a fixed order within one batch (domestic, euro, foreign payments) — the library sorts the export automatically, you can add payments in any order. For more properties check the classes themselves. diff --git a/src/Entity/Transaction/TransactionList.php b/src/Entity/Transaction/TransactionList.php index 16951f3..589c1b0 100644 --- a/src/Entity/Transaction/TransactionList.php +++ b/src/Entity/Transaction/TransactionList.php @@ -12,6 +12,14 @@ class TransactionList implements IteratorAggregate { + // Bank rejects the import file unless types keep this order (FIO API docs 6.3). + // Keyed by XML element name so custom Transaction subclasses sort correctly too. + private const EXPORT_ORDER = [ + DomesticTransaction::NAME => 0, + EuroTransaction::NAME => 1, + ForeignTransaction::NAME => 2, + ]; + /** @var Transaction[] */ protected array $transactions = []; @@ -38,10 +46,16 @@ public function toXml(): string */ public function toArray(): array { + // Stable sort, insertion order is kept within each type + $transactions = $this->transactions; + usort( + $transactions, + static fn (Transaction $a, Transaction $b): int => (self::EXPORT_ORDER[$a::NAME] ?? PHP_INT_MAX) <=> (self::EXPORT_ORDER[$b::NAME] ?? PHP_INT_MAX) + ); + $arr = []; - /** @var Transaction $transaction */ - foreach ($this->transactions as $transaction) { + foreach ($transactions as $transaction) { $arr[] = [$transaction::NAME => $transaction->toArray()]; } diff --git a/tests/cases/Entity/Transaction/TransactionListTest.phpt b/tests/cases/Entity/Transaction/TransactionListTest.phpt new file mode 100644 index 0000000..9d9c583 --- /dev/null +++ b/tests/cases/Entity/Transaction/TransactionListTest.phpt @@ -0,0 +1,131 @@ +setAccountFrom('1234562'); + $t->setAmount(100.00); + $t->setAccountTo('222444666'); + $t->setBankCode('0300'); + $t->setDate(new DateTimeImmutable('2013-04-25')); + $t->setVs($vs); + + return $t; +} + +function createEuro(): EuroTransaction +{ + $t = new EuroTransaction(); + $t->setAccountFrom('1234562'); + $t->setAmount(100.00); + $t->setAccountTo('AT611904300234573201'); + $t->setDate(new DateTimeImmutable('2013-04-25')); + $t->setBenefName('Hans Gruber'); + + return $t; +} + +function createForeign(): ForeignTransaction +{ + $t = new ForeignTransaction(); + $t->setAccountFrom('1234562'); + $t->setCurrency('USD'); + $t->setAmount(100.00); + $t->setAccountTo('PK36SCBL0000001123456702'); + $t->setBic('ALFHPKKAXXX'); + $t->setDate(new DateTimeImmutable('2013-04-25')); + $t->setBenefName('Amir Khan'); + $t->setBenefStreet('Nishtar Rd 13'); + $t->setBenefCity('Karachi'); + $t->setBenefCountry('PK'); + $t->setRemittanceInfo1('Payment for hotel'); + $t->setDetailsOfCharges(ForeignTransaction::CHARGES_SHA); + $t->setPaymentReason(348); + + return $t; +} + +// Export sorts types into the order required by the bank +Toolkit::test(function (): void { + $list = new TransactionList(); + $list->addTransaction(createForeign()); + $list->addTransaction(createEuro()); + $list->addTransaction(createDomestic('1')); + + $names = array_map(static fn (array $item): string => array_key_first($item), $list->toArray()); + + Assert::same(['DomesticTransaction', 'T2Transaction', 'ForeignTransaction'], $names); +}); + +// Insertion order is kept within the same type (stable sort) +Toolkit::test(function (): void { + $list = new TransactionList(); + $list->addTransaction(createDomestic('111')); + $list->addTransaction(createForeign()); + $list->addTransaction(createDomestic('222')); + + $arr = $list->toArray(); + + Assert::same('111', $arr[0]['DomesticTransaction']['vs']); + Assert::same('222', $arr[1]['DomesticTransaction']['vs']); + Assert::same(['DomesticTransaction', 'DomesticTransaction', 'ForeignTransaction'], array_map(static fn (array $item): string => array_key_first($item), $arr)); +}); + +// Custom subclass sorts by its NAME, not by class +Toolkit::test(function (): void { + $custom = new class extends Transaction { + + public const NAME = 'DomesticTransaction'; + + public function isValid(): bool + { + return true; + } + + /** + * @return mixed[] + */ + public function toArray(): array + { + return array_merge(parent::toArray(), ['date' => $this->date, 'bankCode' => '0300']); + } + + }; + $custom->setAccountFrom('1234562'); + $custom->setAmount(1.00); + $custom->setAccountTo('222444666'); + $custom->setDate(new DateTimeImmutable('2013-04-25')); + + $list = new TransactionList(); + $list->addTransaction(createEuro()); + $list->addTransaction($custom); + + $names = array_map(static fn (array $item): string => array_key_first($item), $list->toArray()); + + Assert::same(['DomesticTransaction', 'T2Transaction'], $names); +}); + +// Iterator keeps insertion order +Toolkit::test(function (): void { + $foreign = createForeign(); + $domestic = createDomestic('1'); + + $list = new TransactionList(); + $list->addTransaction($foreign); + $list->addTransaction($domestic); + + Assert::same([$foreign, $domestic], iterator_to_array($list)); +});