From 5c48ec59555fa46a0498ba07c2d8e0029fcfccf4 Mon Sep 17 00:00:00 2001 From: Cosnavel <42392570+Cosnavel@users.noreply.github.com> Date: Tue, 15 Sep 2026 14:01:09 +0200 Subject: [PATCH 1/2] Fix the MT940 closing balance: keep :62F: with its statement, stop negating twice The :62F: closing balance was keyed by its own date, so a bank or proxy that dates it with the next booking day (seen with the FinTS Connect proxy for PayPal) lost it entirely and Statement::getEndBalance() returned null. Attach it to the statement opened by the preceding :60F:/:60M: instead, and also accept a :62M: intermediate balance. The parser already negates a debit closing balance and StatementOfAccount negated it again, so an overdrawn account reported a positive end balance. Derive the sign from credit_debit alone, which also keeps the (unsigned) CAMT input correct. --- Tests/Unit/MT940/MT940ClosingBalanceTest.php | 64 +++++++++++++++++++ src/MT940/MT940.php | 32 +++++----- .../StatementOfAccount/StatementOfAccount.php | 5 +- 3 files changed, 84 insertions(+), 17 deletions(-) create mode 100644 Tests/Unit/MT940/MT940ClosingBalanceTest.php diff --git a/Tests/Unit/MT940/MT940ClosingBalanceTest.php b/Tests/Unit/MT940/MT940ClosingBalanceTest.php new file mode 100644 index 00000000..11d59c32 --- /dev/null +++ b/Tests/Unit/MT940/MT940ClosingBalanceTest.php @@ -0,0 +1,64 @@ +parse(self::statement(':60F:D260717EUR2,27', ':62F:C260718EUR1234,56')); + + $this->assertSame('1234.56', $parsed['2026-07-17']['end_balance']['amount']); + $this->assertSame(MT940::CD_CREDIT, $parsed['2026-07-17']['end_balance']['credit_debit']); + $this->assertSame(1234.56, StatementOfAccount::fromMT940Array($parsed)->getStatements()[0]->getEndBalance()); + } + + public function testIntermediateClosingBalanceIsUsed(): void + { + $parsed = (new MT940())->parse(self::statement(':60F:C260717EUR1234,56', ':62M:C260717EUR1554,64')); + + $this->assertSame('1554.64', $parsed['2026-07-17']['end_balance']['amount']); + } + + /** + * The parser negates a debit closing balance and the model used to negate it again, so an overdrawn account + * reported a positive end balance. + */ + public function testDebitClosingBalanceIsNegative(): void + { + $parsed = (new MT940())->parse(self::statement(':60F:C260717EUR10,00', ':62F:D260717EUR5000,00')); + + $this->assertSame(MT940::CD_DEBIT, $parsed['2026-07-17']['end_balance']['credit_debit']); + $this->assertSame(-5000.0, StatementOfAccount::fromMT940Array($parsed)->getStatements()[0]->getEndBalance()); + } + + public function testStatementWithoutClosingBalanceHasNoEndBalance(): void + { + $parsed = (new MT940())->parse(self::statement(':60F:D260717EUR2,27', '')); + + $this->assertArrayNotHasKey('end_balance', $parsed['2026-07-17']); + $this->assertNull(StatementOfAccount::fromMT940Array($parsed)->getStatements()[0]->getEndBalance()); + } + + private static function statement(string $openingBalance, string $closingBalance): string + { + $crlf = "\r\n"; + + return ':20:STARTUMS' . $crlf + . ':25:92020000/7210891793' . $crlf + . ':28C:0' . $crlf + . $openingBalance . $crlf + . ':61:2607170717CR320,08N062NONREF' . $crlf + . ':86:654801?00654801?20PAYMENT?32Max Mustermann' . $crlf + . ($closingBalance !== '' ? $closingBalance . $crlf : '') + . '-' . $crlf; + } +} diff --git a/src/MT940/MT940.php b/src/MT940/MT940.php index 9bf24f71..ec0f67e3 100644 --- a/src/MT940/MT940.php +++ b/src/MT940/MT940.php @@ -135,26 +135,26 @@ public function parse(string $rawData): array $trx[count($trx) - 1]['description'] = $this->parseDescription($description, $trx[count($trx) - 1]); } elseif ( - preg_match('/^62F:/', $day[$i]) // handle end balance + preg_match('/^62(F|M):/', $day[$i]) // handle end balance + && $soaDate !== null && isset($result[$soaDate]) ) { - // remove 62F: for better parsing + // The closing balance belongs to the statement opened by the preceding :60F:/:60M:, even when it + // carries a different date (e.g. the next booking day, as some banks and proxies do). It used to + // be keyed by its own date instead and was silently lost in that case. A :62M: intermediate + // balance is taken as well; a later :62F: for the same statement overwrites it. $day[$i] = substr($day[$i], 4); - $soaDate = $this->getDate(substr($day[$i], 1, 6)); - - if (isset($result[$soaDate])) { - // $result[$soaDate] = ['end_balance' => []]; - - $amount = str_replace(',', '.', substr($day[$i], 10, -1)); - $cdMark = substr($day[$i], 0, 1); - if ($cdMark == 'C') { - $result[$soaDate]['end_balance']['credit_debit'] = static::CD_CREDIT; - } elseif ($cdMark == 'D') { - $result[$soaDate]['end_balance']['credit_debit'] = static::CD_DEBIT; - $amount *= -1; - } - $result[$soaDate]['end_balance']['amount'] = $amount; + // The statement terminator "-" sticks to the last field once the line breaks are removed above. + $amount = str_replace(',', '.', rtrim(substr($day[$i], 10), "-\r\n ")); + $cdMark = substr($day[$i], 0, 1); + if ($cdMark == 'C') { + $result[$soaDate]['end_balance']['credit_debit'] = static::CD_CREDIT; + } elseif ($cdMark == 'D') { + $result[$soaDate]['end_balance']['credit_debit'] = static::CD_DEBIT; + $amount *= -1; } + + $result[$soaDate]['end_balance']['amount'] = $amount; } } } diff --git a/src/Model/StatementOfAccount/StatementOfAccount.php b/src/Model/StatementOfAccount/StatementOfAccount.php index 05053ec5..be17eb0b 100644 --- a/src/Model/StatementOfAccount/StatementOfAccount.php +++ b/src/Model/StatementOfAccount/StatementOfAccount.php @@ -75,7 +75,10 @@ public static function fromMT940Array(array $array): StatementOfAccount $statementModel->setStartBalance((float) $statement['start_balance']['amount']); } if (isset($statement['end_balance'])) { - $statementModel->setEndBalance((float) $statement['end_balance']['amount'] * ($statement['end_balance']['credit_debit'] == MT940::CD_CREDIT ? 1 : -1)); + // The MT940 parser already negates a debit closing balance, the CAMT parser does not, so derive + // the sign from credit_debit alone instead of negating a possibly negative amount a second time. + $endBalance = abs((float) $statement['end_balance']['amount']); + $statementModel->setEndBalance($statement['end_balance']['credit_debit'] == MT940::CD_CREDIT ? $endBalance : -$endBalance); } if (isset($statement['start_balance']['credit_debit'])) { $statementModel->setCreditDebit($statement['start_balance']['credit_debit']); From 3749e89771385faa6a1677e1c1ce0b033f319ea8 Mon Sep 17 00:00:00 2001 From: Cosnavel <42392570+Cosnavel@users.noreply.github.com> Date: Sat, 19 Sep 2026 01:57:36 +0200 Subject: [PATCH 2/2] Keep the MT940 closing balance unsigned like every other balance in the parsed array The start balance and both CAMT balances are stored unsigned with a separate credit_debit flag; only the MT940 closing balance was negated in the parser (turning the string into a float on the way), which is why the model had to guard against negating it twice. Drop the negation in the parser and let StatementOfAccount::fromMT940Array() apply the direction once, as it always did. getParsedMT940() now reports debit closing balances unsigned as well. --- Tests/Unit/MT940/MT940ClosingBalanceTest.php | 6 ++++-- src/MT940/MT940.php | 3 ++- src/Model/StatementOfAccount/StatementOfAccount.php | 5 +---- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Tests/Unit/MT940/MT940ClosingBalanceTest.php b/Tests/Unit/MT940/MT940ClosingBalanceTest.php index 11d59c32..4e114757 100644 --- a/Tests/Unit/MT940/MT940ClosingBalanceTest.php +++ b/Tests/Unit/MT940/MT940ClosingBalanceTest.php @@ -29,13 +29,15 @@ public function testIntermediateClosingBalanceIsUsed(): void } /** - * The parser negates a debit closing balance and the model used to negate it again, so an overdrawn account - * reported a positive end balance. + * The parser used to negate a debit closing balance (unlike the start balance and unlike the CAMT parser) and + * the model negated it again, so an overdrawn account reported a positive end balance. The parsed amount now + * stays unsigned and the model applies the direction once. */ public function testDebitClosingBalanceIsNegative(): void { $parsed = (new MT940())->parse(self::statement(':60F:C260717EUR10,00', ':62F:D260717EUR5000,00')); + $this->assertSame('5000.00', $parsed['2026-07-17']['end_balance']['amount']); $this->assertSame(MT940::CD_DEBIT, $parsed['2026-07-17']['end_balance']['credit_debit']); $this->assertSame(-5000.0, StatementOfAccount::fromMT940Array($parsed)->getStatements()[0]->getEndBalance()); } diff --git a/src/MT940/MT940.php b/src/MT940/MT940.php index ec0f67e3..38d91d9f 100644 --- a/src/MT940/MT940.php +++ b/src/MT940/MT940.php @@ -145,13 +145,14 @@ public function parse(string $rawData): array $day[$i] = substr($day[$i], 4); // The statement terminator "-" sticks to the last field once the line breaks are removed above. + // Like the start balance (and the CAMT parser), the amount stays unsigned; the direction is in + // credit_debit and applied by StatementOfAccount::fromMT940Array(). $amount = str_replace(',', '.', rtrim(substr($day[$i], 10), "-\r\n ")); $cdMark = substr($day[$i], 0, 1); if ($cdMark == 'C') { $result[$soaDate]['end_balance']['credit_debit'] = static::CD_CREDIT; } elseif ($cdMark == 'D') { $result[$soaDate]['end_balance']['credit_debit'] = static::CD_DEBIT; - $amount *= -1; } $result[$soaDate]['end_balance']['amount'] = $amount; diff --git a/src/Model/StatementOfAccount/StatementOfAccount.php b/src/Model/StatementOfAccount/StatementOfAccount.php index be17eb0b..05053ec5 100644 --- a/src/Model/StatementOfAccount/StatementOfAccount.php +++ b/src/Model/StatementOfAccount/StatementOfAccount.php @@ -75,10 +75,7 @@ public static function fromMT940Array(array $array): StatementOfAccount $statementModel->setStartBalance((float) $statement['start_balance']['amount']); } if (isset($statement['end_balance'])) { - // The MT940 parser already negates a debit closing balance, the CAMT parser does not, so derive - // the sign from credit_debit alone instead of negating a possibly negative amount a second time. - $endBalance = abs((float) $statement['end_balance']['amount']); - $statementModel->setEndBalance($statement['end_balance']['credit_debit'] == MT940::CD_CREDIT ? $endBalance : -$endBalance); + $statementModel->setEndBalance((float) $statement['end_balance']['amount'] * ($statement['end_balance']['credit_debit'] == MT940::CD_CREDIT ? 1 : -1)); } if (isset($statement['start_balance']['credit_debit'])) { $statementModel->setCreditDebit($statement['start_balance']['credit_debit']);