Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions Tests/Unit/MT940/MT940ClosingBalanceTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
<?php

namespace Fhp\Tests\Unit\MT940;

use Fhp\Model\StatementOfAccount\StatementOfAccount;
use Fhp\MT940\MT940;
use PHPUnit\Framework\TestCase;

class MT940ClosingBalanceTest extends TestCase
{
/**
* The closing balance of the first statement carries the next day's date, as e.g. the FinTS Connect proxy that
* fronts PayPal sends it. It used to be dropped because the parser keyed it by its own date.
*/
public function testClosingBalanceWithDifferentDateStaysWithItsStatement(): void
{
$parsed = (new MT940())->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;
}
}
33 changes: 17 additions & 16 deletions src/MT940/MT940.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just for the record, as you've said, removing this amount inversion makes things consistent with line 55 above, which is nice.

}

$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;
}
}
}
Expand Down