diff --git a/Tests/Unit/CAMT/CAMTBalanceTest.php b/Tests/Unit/CAMT/CAMTBalanceTest.php new file mode 100644 index 00000000..ddff8a64 --- /dev/null +++ b/Tests/Unit/CAMT/CAMTBalanceTest.php @@ -0,0 +1,115 @@ +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 $balances type, amount, CRDT/DBIT, date + */ + private function buildReport(array $balances): string + { + $ns = self::NS; + $balanceXml = ''; + foreach ($balances as [$type, $amount, $creditDebit, $date]) { + $balanceXml .= "{$type}{$amount}" + . "{$creditDebit}
{$date}
"; + } + + return << + + + 1 + DE44500105175407324931 + {$balanceXml} + + 12963.23 + CRDT + BOOK +
2026-07-17
+
2026-07-17
+ + + + Max Mustermann + DE89370400440532013000 + + Rechnung 4711 + + +
+
+
+ +XML; + } +} diff --git a/src/CAMT/CAMT.php b/src/CAMT/CAMT.php index 0ce06fd5..c2fed59b 100644 --- a/src/CAMT/CAMT.php +++ b/src/CAMT/CAMT.php @@ -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'); @@ -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' => [], ]; } @@ -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, ]; } @@ -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, + ]; } /**