diff --git a/.docs/README.md b/.docs/README.md index c054fd7..c48f18a 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) + +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. From `FioManager` you get `PaymentService` which has two methods: 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/DomesticTransaction.php b/src/Entity/Transaction/DomesticTransaction.php index b71c948..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,94 +22,60 @@ final class DomesticTransaction extends Transaction // Příkaz k inkasu public const PAYMENT_TYPE_COLLECTION = 431022; - private int $paymentType; - private string $bankCode; - private string $ks; - - private string $vs; + private ?string $ks = null; - private string $ss; + private ?string $vs = null; - private string $messageForRecipient; + private ?string $ss = null; - private string $comment; + private ?string $messageForRecipient = null; - private int $paymentReason; - - public function setPaymentType(int $paymentType): void - { - if (strlen((string) $paymentType) !== 6) { - throw new InvalidPropertyException('$paymentType has to be 6 digits long.'); - } - - $this->paymentType = $paymentType; - } + private ?string $comment = null; 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/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/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/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)); + } + } + } 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/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']); +}); 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); +}); 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); +}); 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)); +});