diff --git a/Tests/Unit/MT940/MT940ClosingBalanceTest.php b/Tests/Unit/MT940/MT940ClosingBalanceTest.php new file mode 100644 index 00000000..4e114757 --- /dev/null +++ b/Tests/Unit/MT940/MT940ClosingBalanceTest.php @@ -0,0 +1,66 @@ +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 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()); + } + + 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..38d91d9f 100644 --- a/src/MT940/MT940.php +++ b/src/MT940/MT940.php @@ -135,26 +135,27 @@ 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. + // 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; } + + $result[$soaDate]['end_balance']['amount'] = $amount; } } }