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
115 changes: 115 additions & 0 deletions Tests/Unit/CAMT/CAMTBalanceTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
<?php

namespace Fhp\Tests\Unit\CAMT;

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

/**
* The balances of a camt.052 report are typed: OPBD is the balance at the start of the day, ITBD/CLBD the balance
* after the reported bookings. The parser used to report OPBD as the end balance as well, which froze
* {@link Statement::getEndBalance()} at the day's opening value (observed at an Atruvia/Volksbank account that showed
* -18,59 EUR while 12.944,64 EUR of credits had already been booked that day).
*/
class CAMTBalanceTest extends TestCase
{
private const NS = 'urn:iso:std:iso:20022:tech:xsd:camt.052.001.02';

public function testInterimBookedBalanceIsTheEndBalance(): void
{
$result = (new CAMT())->parse([$this->buildReport([
['OPBD', '18.59', 'DBIT', '2026-07-17'],
['ITBD', '12944.64', 'CRDT', '2026-07-17'],
])]);

$statement = $result['2026-07-17'];
$this->assertSame(18.59, $statement['start_balance']['amount']);
$this->assertSame(MT940::CD_DEBIT, $statement['start_balance']['credit_debit']);
$this->assertSame(12944.64, $statement['end_balance']['amount']);
$this->assertSame(MT940::CD_CREDIT, $statement['end_balance']['credit_debit']);

$model = StatementOfAccount::fromCAMTArray($result)->getStatements()[0];
$this->assertSame(18.59, $model->getStartBalance());
$this->assertSame(Statement::CD_DEBIT, $model->getCreditDebit());
$this->assertSame(12944.64, $model->getEndBalance());
}

public function testClosingBookedBalanceWinsOverInterimBalance(): void
{
$result = (new CAMT())->parse([$this->buildReport([
['ITBD', '100.00', 'CRDT', '2026-07-17'],
['CLBD', '150.00', 'CRDT', '2026-07-17'],
['OPBD', '10.00', 'CRDT', '2026-07-17'],
])]);

$this->assertSame(10.0, $result['2026-07-17']['start_balance']['amount']);
$this->assertSame(150.0, $result['2026-07-17']['end_balance']['amount']);
}

public function testDebitClosingBalanceIsReportedAsNegativeEndBalance(): void
{
$result = (new CAMT())->parse([$this->buildReport([
['OPBD', '10.00', 'CRDT', '2026-07-17'],
['CLBD', '40.00', 'DBIT', '2026-07-17'],
])]);

$model = StatementOfAccount::fromCAMTArray($result)->getStatements()[0];
$this->assertSame(-40.0, $model->getEndBalance());
}

public function testAvailableBalancesAndOpeningBalanceAloneYieldNoEndBalance(): void
{
$result = (new CAMT())->parse([$this->buildReport([
['OPBD', '18.59', 'DBIT', '2026-07-17'],
['ITAV', '12944.64', 'CRDT', '2026-07-17'],
])]);

$this->assertSame(18.59, $result['2026-07-17']['start_balance']['amount']);
$this->assertArrayNotHasKey('end_balance', $result['2026-07-17']);
$this->assertNull(StatementOfAccount::fromCAMTArray($result)->getStatements()[0]->getEndBalance());
}

/**
* @param array<int, array{0: string, 1: string, 2: string, 3: string}> $balances type, amount, CRDT/DBIT, date
*/
private function buildReport(array $balances): string
{
$ns = self::NS;
$balanceXml = '';
foreach ($balances as [$type, $amount, $creditDebit, $date]) {
$balanceXml .= "<Bal><Tp><CdOrPrtry><Cd>{$type}</Cd></CdOrPrtry></Tp><Amt Ccy=\"EUR\">{$amount}</Amt>"
. "<CdtDbtInd>{$creditDebit}</CdtDbtInd><Dt><Dt>{$date}</Dt></Dt></Bal>";
}

return <<<XML
<Document xmlns="{$ns}">
<BkToCstmrAcctRpt>
<Rpt>
<Id>1</Id>
<Acct><Id><IBAN>DE44500105175407324931</IBAN></Id></Acct>
{$balanceXml}
<Ntry>
<Amt Ccy="EUR">12963.23</Amt>
<CdtDbtInd>CRDT</CdtDbtInd>
<Sts>BOOK</Sts>
<BookgDt><Dt>2026-07-17</Dt></BookgDt>
<ValDt><Dt>2026-07-17</Dt></ValDt>
<NtryDtls>
<TxDtls>
<RltdPties>
<Dbtr><Nm>Max Mustermann</Nm></Dbtr>
<DbtrAcct><Id><IBAN>DE89370400440532013000</IBAN></Id></DbtrAcct>
</RltdPties>
<RmtInf><Ustrd>Rechnung 4711</Ustrd></RmtInf>
</TxDtls>
</NtryDtls>
</Ntry>
</Rpt>
</BkToCstmrAcctRpt>
</Document>
XML;
}
}
62 changes: 39 additions & 23 deletions src/CAMT/CAMT.php
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ private function parseReport(\SimpleXMLElement $report, string $ns, array &$resu

// Get account balances
$balances = $this->parseBalances($report, $ns);
$startBalance = $balances['start'];
$endBalance = $balances['end'];

// Parse entries (transactions)
$entries = $report->xpath('.//c:Ntry');
Expand All @@ -95,7 +97,7 @@ private function parseReport(\SimpleXMLElement $report, string $ns, array &$resu
$dateKey = $transaction['booking_date'];
if (!isset($result[$dateKey])) {
$result[$dateKey] = [
'start_balance' => $balances,
'start_balance' => $startBalance,
'transactions' => [],
];
}
Expand All @@ -104,22 +106,26 @@ private function parseReport(\SimpleXMLElement $report, string $ns, array &$resu
}

// If we have balances but no transactions, still create an entry
if (!empty($balances) && empty($entries)) {
$dateKey = $balances['date'] ?? date('Y-m-d');
if (!empty($startBalance) && empty($entries)) {
$dateKey = $startBalance['date'] ?? date('Y-m-d');
if (!isset($result[$dateKey])) {
$result[$dateKey] = [
'start_balance' => $balances,
'start_balance' => $startBalance,
'transactions' => [],
];
}
}

// Set end balances
// Set end balances. Only a closing balance qualifies: reporting the opening balance here (as this parser did
// before) freezes StatementOfAccount::getEndBalance() at the day's start value while bookings keep coming in.
if ($endBalance === null) {
return;
}
foreach ($result as $dateKey => &$statement) {
if (!isset($statement['end_balance']) && !empty($balances)) {
if (!isset($statement['end_balance'])) {
$statement['end_balance'] = [
'amount' => $balances['amount'] ?? 0,
'credit_debit' => $balances['credit_debit'] ?? MT940::CD_CREDIT,
'amount' => $endBalance['amount'],
'credit_debit' => $endBalance['credit_debit'],
'date' => $dateKey,
];
}
Expand All @@ -129,40 +135,50 @@ private function parseReport(\SimpleXMLElement $report, string $ns, array &$resu
/**
* Parse balance information from report
*
* @return array Balance information
* The opening balance (OPBD, or PRCD = previously closed booked as a fallback) becomes the start balance. The end
* balance is the closing booked balance (CLBD) or, on an intraday camt.052 report, the interim booked balance
* (ITBD). The "available" variants (CLAV/ITAV) are deliberately ignored because they include unbooked amounts and
* therefore do not correspond to the MT940 closing balance (:62F:) that the rest of the library is modelled on.
*
* @return array{start: array, end: ?array} Start balance ([] if none) and end balance (null if none), each with
* keys amount, currency, credit_debit, date.
*/
private function parseBalances(\SimpleXMLElement $report, string $ns): array
{
$report->registerXPathNamespace('c', $ns);

// Try to find opening balance (OPBD) or closing balance (CLBD)
$balances = $report->xpath('.//c:Bal');
if ($balances === false || empty($balances)) {
return [];
return ['start' => [], 'end' => null];
}

$result = [];
$byType = [];
$first = null;
foreach ($balances as $balance) {
$balance->registerXPathNamespace('c', $ns);

$type = (string) $balance->xpath('.//c:Tp/c:CdOrPrtry/c:Cd')[0] ?? '';
$type = (string) ($balance->xpath('.//c:Tp/c:CdOrPrtry/c:Cd')[0] ?? '');
$amount = (float) ($balance->xpath('.//c:Amt')[0] ?? 0);
$currency = (string) ($balance->xpath('.//c:Amt/@Ccy')[0] ?? 'EUR');
$creditDebit = (string) ($balance->xpath('.//c:CdtDbtInd')[0] ?? 'CRDT');
$date = (string) ($balance->xpath('.//c:Dt/c:Dt')[0] ?? '');

// Use opening balance if available
if ($type === 'OPBD' || empty($result)) {
$result = [
'amount' => $amount,
'currency' => $currency,
'credit_debit' => $creditDebit === 'DBIT' ? MT940::CD_DEBIT : MT940::CD_CREDIT,
'date' => $date,
];
}
$parsed = [
'amount' => $amount,
'currency' => $currency,
'credit_debit' => $creditDebit === 'DBIT' ? MT940::CD_DEBIT : MT940::CD_CREDIT,
'date' => $date,
];
$first ??= $parsed;
// Banks may send several balances of the same type (e.g. one per day in a multi-day report); the last one
// is the most recent.
$byType[$type] = $parsed;
}

return $result;
return [
'start' => $byType['OPBD'] ?? $byType['PRCD'] ?? $first,
'end' => $byType['CLBD'] ?? $byType['ITBD'] ?? null,
];
}

/**
Expand Down