Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
64 changes: 64 additions & 0 deletions Tests/Unit/MT940/MT940ClosingBalanceTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?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 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;
}
}
32 changes: 16 additions & 16 deletions src/MT940/MT940.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

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.
$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;
}
}
}
Expand Down
5 changes: 4 additions & 1 deletion src/Model/StatementOfAccount/StatementOfAccount.php
Original file line number Diff line number Diff line change
Expand Up @@ -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

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.

Makes me wonder whether we should instead adjust the parsers to be consistent.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in 3749e89: the parser keeps the closing balance unsigned like the start balance and the CAMT balances, and this line is back to applying the sign once.

// 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']);
Expand Down