webwingscz/bank-statements

Parser of bank account statements: the Czech and Slovak ABO/GPC format, ISO 20022 camt.053, SWIFT MT940 and Wise CSV exports. No runtime dependencies.

Maintainers

Package info

github.com/webwingscz/bank-statements

pkg:composer/webwingscz/bank-statements

Transparency log

Statistics

Installs: 19

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

1.2.0 2026-07-30 13:07 UTC

This package is auto-updated.

Last update: 2026-07-31 18:00:02 UTC


README

Parser of bank account statements: the Czech and Slovak ABO/GPC format, ISO 20022 camt.053, SWIFT MT940 and Wise CSV exports.

Česká verze: README.cs.md

  • No runtime dependencies at all — PHP 8.3+, not even an extension. ext-dom is needed for the XML formats only, and asked for when a camt file is actually read.
  • Immutable, typed value objectsreadonly classes, enums, no magic.
  • Amounts in minor units — no floating point rounding between the file and your application.
  • Four formats, one shape — a GPC file, a camt.053 document, an MT940 message and a Wise export come out as the same Statement and Transaction; what only one of them carries lives behind Transaction::$detail.
  • Bank dialects — the ABO format is only nominally standard; the deviations are explicit instead of guessed.
  • Symbols out of formats that have none — camt.053 and MT940 are international messages with no variable, constant or specific symbol. The labelled forms Czech and Slovak banks write into the free text are read; an unlabelled number is never taken for a symbol.
  • PHPStan level max, 304 unit tests.

Installation

composer require webwingscz/bank-statements

Usage — ABO/GPC

use Webwings\BankStatements\Abo\AboDialect;
use Webwings\BankStatements\Abo\AboParser;

$parser = new AboParser(AboDialect::fio());

foreach ($parser->parseFile('statement.gpc') as $statement) {
    echo $statement->accountNumber?->toString(), "\n";   // 8310192897/2010
    echo $statement->closingBalance->toDecimalString(), "\n";

    foreach ($statement as $transaction) {
        printf(
            "%s  %10s  %-20s  VS %s\n",
            $transaction->date()?->format('Y-m-d'),
            $transaction->amount->toDecimalString(),   // "-24.20"
            $transaction->payerOrPayeeName() ?? '',
            $transaction->variableSymbol ?? '-',
        );
    }
}

Parsing from a string works the same way:

$statements = $parser->parseString(file_get_contents('statement.gpc'));

Pick the dialect by bank code

A GPC file does not contain the bank code of the account it belongs to, yet banks disagree on two details. Tell the parser which bank produced the file — you always know that, the file does not.

new AboParser(AboDialect::forBankCode('0800'));   // Česká spořitelna
new AboParser(AboDialect::fio());                 // 2010
new AboParser(AboDialect::csob());                // 0300
new AboParser(AboDialect::standard());            // anything without a known deviation

What differs:

Reversal posting codes ISO 4217 currency in bytes 118–122
Most banks (FIO, ČSOB, KB, …) 4 = debit, 5 = credit only FIO and ČSOB
Česká spořitelna 3 = debit, 4 = credit no — those bytes hold other fields

Getting this wrong is not harmless: with the wrong dialect the value 4 flips a reversal from debit to credit, and reading the currency where it does not exist invents one. Unknown posting codes therefore raise UnknownPostingCode rather than being silently skipped.

Own variant? Build one:

AboDialect::custom(
    ['1' => PostingCode::Debit, '2' => PostingCode::Credit],
    hasCurrencyInTransaction: true,
    bankCode: '6210',
);

Strict mode

By default unknown record types (such as Komerční banka's UHL1 header) and orphaned detail records are skipped, so that real-world exports parse. Pass strict: true to reject them instead:

new AboParser(AboDialect::fio(), strict: true);

What is parsed from ABO/GPC

Record Meaning Mapped to
074 statement header Statement — account, client name, opening/closing balance, turnovers, serial number, dates
075 transaction Transaction — signed amount, posting code, counter-account, VS/KS/SS, document id, note, value and due date, currency
076 deduction date and counter-party name Transaction::$deductionDate, Transaction::$counterPartyName
078 free text AV1, AV2 Transaction::$descriptionLines
079 free text AV3, AV4 Transaction::$descriptionLines

A file may contain several 074 blocks; parsing therefore returns a StatementList.

The full field layout with 1-based byte positions is documented in the class docblock of AboParser.

Usage — Wise CSV

A Wise (formerly TransferWise) export is read into the same objects as a GPC file:

use Webwings\BankStatements\AccountNumber;
use Webwings\BankStatements\Wise\WiseParser;

$parser = new WiseParser(
    accountNumber: AccountNumber::fromParts('', '8310192897', '2010'),
    clientName: 'Webwings s.r.o.',
);

$statement = $parser->parseFile('statement_3807780_EUR_2026-06-01_2026-06-30.csv')->first();

echo $statement->currency;                            // EUR
echo $statement->closingBalance->toDecimalString();    // 1177.41

foreach ($statement as $transaction) {
    printf(
        "%s  %10s  %s\n",
        $transaction->date()?->format('Y-m-d'),
        $transaction->amount->toDecimalString(),       // "-1083.35"
        $transaction->payerOrPayeeName() ?? '',        // "Ovhcloud Dublin"
    );
}
Column Mapped to
Amount Transaction::$amount, already signed by Wise
Transaction Type Transaction::$postingCode, cross-checked against the sign
Date Transaction::$valueDate and $dueDate, at midnight
TransferWise ID Transaction::$documentId
Description Transaction::$note
Payment Reference, Note Transaction::$descriptionLines
Currency Transaction::$currency and Statement::$currency
Payer Name, Payee Name, Merchant Transaction::$counterPartyName
Payee Account Number Transaction::$counterAccount, for outgoing items
Running Balance opening and closing balance of the Statement
everything else WiseTransactionDetail behind Transaction::$detail

The account number is not in the file

A Wise CSV names no account at all. The export file name carries a balance id — a Wise profile holds one balance per currency — and which real account number that stands for is knowledge only you have. WiseFileName reads the name so that you can look the account up:

use Webwings\BankStatements\Wise\WiseFileName;

$name = WiseFileName::tryParse($path);   // balanceId "3807780", currency "EUR", from, to
$parser = new WiseParser(accountNumber: $accountsByBalanceId[$name->balanceId] ?? null);

The same name gives the statement its period. Without it — when parsing from a string, or from a renamed file — the period is taken from the oldest and newest movement instead.

There is no variable, constant or specific symbol in a Wise statement either. Deriving one from Payment Reference, which holds free text such as VF2 26002 WEBWINGS, would invent data, so the symbols stay null and the reference is kept as free text.

Columns are found by name

Wise reorders and extends its column set between exports; the header therefore decides what a cell means, never its position. Unknown columns are ignored, and a missing amount or date column raises MissingColumn.

What only Wise has

An exchange rate, a running balance, a card number, a merchant, a payment reference, the exact timestamp — none of that exists in a GPC file. It is kept in a WiseTransactionDetail:

use Webwings\BankStatements\Wise\WiseDetailsType;
use Webwings\BankStatements\Wise\WiseTransactionDetail;

if ($transaction->detail instanceof WiseTransactionDetail) {
    $transaction->detail->exchangeRate;                              // "20.96460", a string on purpose
    $transaction->detail->exchangeToAmount?->toDecimalString();      // "150000.00"
    $transaction->detail->bookedAt?->format('Y-m-d H:i:s.v');        // the time of day, too
    $transaction->detail->runningBalance?->toDecimalString();
    $transaction->detail->cardLastFourDigits;
    $transaction->detail->detailsType === WiseDetailsType::Conversion;
}

An exchange rate stays a string: 20.96460 is not money, has five decimal places and would be the one value in the library that a float could silently change.

Charges are separate items

Wise books its own charges as items of their own, whose id is that of the charged item prefixed with FEE-. A transfer of 150 000 CZK with a 22,16 CZK fee is two rows, and this library keeps them as the two movements the account actually saw — nothing is netted off:

$transaction->detail->isFee();      // true for the FEE- item
$transaction->detail->feeOf();      // "TRANSFER-2185636734"
$transaction->detail->fees;         // on the charged item: the 22.16 it reports

Order, balances and turnovers are derived

Wise exports newest first and writes no statement header. The rows are therefore reordered to chronological order — the way a statement reads and the way the ABO parser returns them — and the header is computed: the closing balance is the running balance of the newest item, the opening balance is the oldest item's running balance minus that item, and the turnovers are the sums of the negative and the positive amounts. On the three production exports this library was built against, closingBalance - openingBalance equals transactionSum() to the heller.

Strict mode

By default an odd row is still worth reading. strict: true rejects three things instead: a row whose Transaction Type contradicts the sign of its amount, a Transaction Details Type this version does not know, and a second currency in one file.

new WiseParser(accountNumber: $account, strict: true);

Without strict mode the sign of the amount wins over a contradicting Transaction Type — the sign is the movement — an unknown details type becomes null, and the statement takes the currency of its first row.

Usage — camt.053 (ISO 20022)

camt.053 is the format Czech banks are moving to and the only one Tatra banka has accepted for statements since 2016. It takes no dialect and no arguments, because everything the GPC parser has to be told from the outside — the currency, the bank code, the account — is in the file:

use Webwings\BankStatements\Camt\Camt053Parser;

$parser = new Camt053Parser();

foreach ($parser->parseFile('statement.xml') as $statement) {
    echo $statement->accountNumber?->toString(), "\n";   // 2600123456/0300
    echo $statement->identification, "\n";               // Stmt/Id
    echo $statement->closingBalance->toDecimalString(), "\n";

    foreach ($statement as $transaction) {
        printf(
            "%s  %10s  %-20s  VS %s\n",
            $transaction->date()?->format('Y-m-d'),
            $transaction->amount->toDecimalString(),
            $transaction->counterPartyName ?? '',
            $transaction->variableSymbol ?? '-',
        );
    }
}

The version is ignored on purpose

Every version from camt.053.001.02 on is read. Elements are looked up by local name and the namespace is not checked: camt.053.001.02 and camt.053.001.08 differ in their namespace URI and a bank raises the version without asking, while the element names the parser reads have not changed in twenty years. A parser bound to one URI stops working the day the bank upgrades.

Where a version genuinely changed the shape, both are read — a party is wrapped in Pty from .08 on, and the BIC element was renamed BICFI.

camt.052 too

An intraday report is the same message under another name: BkToCstmrAcctRpt/Rpt instead of BkToCstmrStmt/Stmt, with an interim balance (ITBD) in place of the closing one. The same parser reads it.

The symbols are not fields

camt has no variable, constant or specific symbol. Czech and Slovak banks write them as labelled tokens, and PaymentSymbols reads exactly those forms:

/VS2026001/SS12/KS0308        creditor reference, Czech Banking Association convention
/VS/2026001/SS/12/KS/0308     the same with separators
VS:1234567890 KS:0308         remittance information

The references the bank labels itself are read first — Refs/Prtry with Tp VS, or a CdtrRefInf whose proprietary type names the symbol — and only then the tokens inside the free text, so a VS mentioned in a description cannot override the real one. An unlabelled number is never taken for a symbol: a ten-digit number in a payment description is more often an invoice number than a variable symbol. Leading zeros are stripped, so KS0308 is 308 — the same value the ABO parser produces for the same payment.

Which balance is the closing balance

A statement carries several and they disagree on purpose. CLBD is the booked closing balance that adds up with the entries; CLAV is what may be spent and also reflects blocked and pending amounts. The booked ones are used (OPBD, PRCD → opening; CLBD, ITBD → closing), the available ones only as a last resort. See CamtBalanceType.

Turnovers come from TxsSummry when the bank sends it and are summed from the entries when it does not.

Batches stay one movement

A payroll file or a direct-debit collection is one Ntry with many TxDtls. The entry amount is what the bank booked, so it stays one Transaction — splitting it would produce movements that do not add up to the balance. The references and parties are those of the first payment, and the count is kept:

use Webwings\BankStatements\Camt\CamtTransactionDetail;

if ($transaction->detail instanceof CamtTransactionDetail && $transaction->detail->isBatch()) {
    echo $transaction->detail->transactionDetailsCount;      // 3
    echo $transaction->detail->batchPaymentInformationId;    // PAYROLL-2026-07
}

Pending entries are kept, and marked

A card authorisation or a payment before cut-off arrives with Sts PDNG. Dropping it would hide a movement the bank reported; counting it into the balance would be wrong. It is parsed and marked:

$booked = array_filter(
    $statement->transactions,
    static fn (Transaction $t): bool => ! $t->detail instanceof CamtTransactionDetail
        || $t->detail->isBooked(),
);

Reversals

A camt entry states the direction the money actually moved, so a reversed debit arrives as CRDT with RvslInd set and comes out as PostingCode::DebitReversal with a positive amount. This is the opposite convention from MT940, where the mark names what is being reversed — both end up as the same four posting codes.

What only camt has

Behind Transaction::$detail sits a CamtTransactionDetail:

Property From
$entryReference, $accountServicerReference NtryRef, AcctSvcrRef
$status, $rawStatus StsBOOK, PDNG, INFO, FUTR
$isReversal RvslInd
$bankTransactionCode BkTxCdPMNT/RCDT/ESCT, and the bank's own code
$bookedAt BookgDt/DtTm, with the time of day kept
$endToEndId, $instructionId, $transactionId, $mandateId, $messageId, $chequeNumber Refs
$creditorReference RmtInf/Strd/CdtrRefInf/Ref
$charges Chrgs — the total, or the sum of the records
$instructedAmount, $instructedCurrency, $exchangeRate AmtDtls — what the payer ordered, before conversion
$debtorName, $creditorName, $ultimateDebtorName, $ultimateCreditorName RltdPties
$counterPartyBic, $counterPartyAccountRaw RltdAgts, RltdPties
$purposeCode, $returnReasonCode, $returnAdditionalInformation Purp, RtrInf
$transactionDetailsCount, $batchPaymentInformationId NtryDtls

$counterPartyAccountRaw exists because Transaction::$counterAccount can only hold a Czech or Slovak account: a German IBAN has no prefix/number/bank-code form, so the typed value stays null and the file's value is kept verbatim rather than invented.

ext-dom, and only for XML

Reading camt needs ext-dom. It is not a Composer requirement — that would make installing the library fail for someone who only ever parses GPC — and is asked for at the moment a camt file is read, with a ExtensionMissing explaining the situation. It ships with PHP and is enabled by default.

A document type declaration is refused rather than expanded, before the parser sees the file. No bank emits a DTD in a statement, and entity expansion is what turns an XML parser into a denial of service or into a reader of local files. Network access is off as well.

Unlike ABO and CSV, an XML file states its own encoding and libxml honours it, so TextDecoder is not used here — there is nothing left to guess.

Strict mode

new Camt053Parser(strict: true);

Rejects an entry with no CdtDbtInd (a camt amount carries no sign, so nothing states the direction), an unknown indicator, an unknown entry status and an unknown balance type. Without strict mode a direction that cannot be read is treated as money leaving the account — of the two guesses, the one that understates the balance is the one that gets noticed — and an unknown status is kept verbatim in $rawStatus.

Usage — MT940 (SWIFT)

MT940 is what a corporate client gets where GPC does not exist: from the Czech and Slovak branches of the foreign banks, from Česká spořitelna and UniCredit through MultiCash, and from Slovenská sporiteľňa. The file does not name the account holder, so that is passed in — the same way the Wise parser takes it:

use Webwings\BankStatements\Mt940\Mt940Parser;

$parser = new Mt940Parser(bankCode: '0800', clientName: 'Vzor s.r.o.');

foreach ($parser->parseFile('AUSZUG.TXT') as $statement) {
    echo $statement->identification, "\n";               // field :20:
    echo $statement->accountNumber?->toString(), "\n";   // 2600123456/0800
    echo $statement->closingBalance->toDecimalString(), "\n";
}

SWIFT block headers ({1:…}{2:…}{4:-}), the - message terminator and continuation lines are handled, so a file straight out of a SWIFT interface and a MultiCash AUSZUG.TXT are both read.

Field 61 is where the money is

:61:2606010602C50000,00NTRFFAKT2026001//5511223344
    ▲     ▲   ▲▲       ▲   ▲           ▲
    │     │   ││       │   │           └─ bank reference
    │     │   ││       │   └───────────── customer reference
    │     │   ││       └───────────────── transaction type identification
    │     │   │└───────────────────────── amount
    │     │   └────────────────────────── debit/credit mark, optional funds code
    │     └────────────────────────────── entry date (MMDD), the day it was booked
    └──────────────────────────────────── value date (YYMMDD)

Two details in there are easy to get wrong and are tested:

The funds code is one letter between the mark and the amount — the third letter of the currency code, K for CZK. Reading it as part of the amount multiplies the movement by ten.

The entry date has no year. Taking the year of the value date is right except over New Year, where a movement with the value date of 2 January 2027 may well have been booked on 31 December 2026. The neighbouring year is taken whenever it puts the two dates closer together.

RC and RD name what is being reversed, not the resulting direction: a reversal of a credit takes the money back out, and becomes PostingCode::CreditReversal with a negative amount.

Field 86 comes in two shapes

SWIFT says field 86 is six lines of free text and nothing more, which is why it is the one part of MT940 that differs between banks. Both shapes banks actually send are read by Mt940Information:

051?00PRICHOZI PLATBA?10123456?20VS:2026001 KS:0308?300800?311234567890?32ODBERATEL A.S.

the MultiCash subfields — ?00 booking text → Transaction::$note, ?10 document number → $documentId, ?20?29 and ?60?63$descriptionLines, ?30/?31$counterAccount, ?32+?33 joined → $counterPartyName — and plain text lines, which become the description lines and are scanned for symbols. A name too long for one subfield is continued in the next, and the two are joined back together.

What only MT940 has

Mt940TransactionDetail holds $transactionTypeIdentification (NTRF a transfer, NCHG a charge, NDDT a direct debit), $fundsCode, $customerReference and $bankReference, $supplementaryDetails (the optional second line of field 61), $bookingText, $transactionCode, $documentNumber, $counterPartyBank, $counterPartyAccountRaw, $textKeyExtension, $isStructured and $rawInformation — field 86 verbatim, for whatever this parser did not map.

What the file does not contain

The account holder, and often not the bank code either; both come from the constructor. Turnovers — there are no turnover fields, so both are summed from the movements, the same as for a Wise export. A currency per movement — an MT940 statement holds one currency, taken from field 60 and put on every transaction.

Continuation messages

A long statement is split into several messages with the same statement number, the middle ones opening with :60M: and closing with :62M:. Each message comes out as one Statement — merging them would mean deciding which balances are the real ones — and the messages of one statement share their $serialNumber.

Strict mode

new Mt940Parser(strict: true);

Rejects a field this parser does not know, which is otherwise skipped. A malformed field 61 or balance is rejected in either mode: those are the numbers that get booked, and there is nothing safe to do with a movement whose amount is unclear.

Design notes

Amounts never become floats. Amount holds minor units (hellers) as an integer and offers toDecimalString(); toFloat() exists but is documented as lossy. ABO stores an unsigned amount and carries the direction in the posting code — Transaction::$amount is already signed, negative for money leaving the account.

Missing values are null, not zero. An all-zero counter-account or variable symbol means "not filled in". Returning 0 or "0" would be indistinguishable from a real zero and makes any hash built over a transaction depend on the parser used.

Text is decoded, not assumed. The ABO specification allows UTF-8 (files without diacritics) and Windows-1250 (files with them). TextDecoder detects UTF-8 by content and converts otherwise, with ISO-8859-2 as a last resort. Byte offsets are always taken before decoding, because the format is byte-oriented.

Conversion works with iconv, with mbstring, or with neither. TextConverter picks whatever the installation offers. This is not a nicety: mbstring cannot convert Windows-1250 — that code page is not among the encodings it supports — and Windows-1250 is precisely what Czech banks use for statements with diacritics. The library therefore ships built-in translation tables (SingleByteEncoding) for Windows-1250 and ISO-8859-2, generated with iconv and verified against it byte by byte in the test suite. An mbstring-only or extension-free installation gets identical results to an iconv one.

// Force a specific mechanism, e.g. to reproduce a production environment
new AboParser(AboDialect::fio(), decoder: new TextDecoder(converter: TextConverter::BuiltIn));

Short lines are tolerated. Reading past the end of a truncated record yields an empty field rather than an error; several exporters omit the trailing filler. A CSV column the export does not have reads as empty for the same reason.

CSV is not split by commas. A statement description regularly contains the delimiter — Dekujeme, Rohlik.cz Prague 8 — and may contain a doubled quote or a line break. CsvReader leaves the work to fgetcsv() and disables its non-standard backslash escaping, which would otherwise swallow the rest of a record ending in a backslash.

One statement, one currency. Statement::$currency exists because a Wise export, a camt.053 document and an MT940 message all state the currency once for the whole statement; a GPC statement leaves it null and carries the currency per transaction, if at all.

What a format does not have is not invented. The three rules above are one rule: null beats a guess. camt.053 and MT940 have no symbol fields, so only labelled tokens are read out of their free text; a foreign IBAN has no domestic form, so $counterAccount stays null and the raw value is kept in the detail; MT940 does not name the account holder, so it is a constructor argument rather than something derived from a description.

Nothing is dropped for being inconvenient. A pending camt entry is not part of the closing balance, but it is a movement the bank reported, so it is parsed and marked rather than skipped. A batched entry is not split, because its sub-payments would not add up to the balance — but the caller is told it was a batch and how many payments it covered. The one thing that does abort parsing is a movement whose amount cannot be read.

XML is parsed by a parser. ABO is read by hand because it is fixed-width bytes and CSV because fgetcsv() is right there, but namespaces, entities and character references are not something to reimplement with regular expressions on data that decides what gets booked. ext-dom is therefore required at the moment a camt file is read — and only then, so the library still installs on any PHP 8.3. Its risk is handled at the door: a DTD is refused rather than expanded.

Development

composer install
composer test        # PHPUnit
composer phpstan     # PHPStan level max
composer cs-check    # PHP-CS-Fixer, dry run
composer ci          # all of the above

The GPC fixtures in tests/fixtures are byte-exact files built from the published specifications. They cover: FIO with a debit item and a currency code, ČSOB with a credit item plus 076/078/079 details and a credit reversal, Česká spořitelna reversal codes, two statements in one file preceded by a UHL1 header, and a Windows-1250 encoded file.

The Wise fixtures mirror the structure of real exports — the full 23-column header, the newest-first order, a charge next to the item it belongs to, a conversion, a cross-currency card payment, a cashback of type UNKNOWN, an IBAN counter-account and a merchant name containing a comma — with invented names, accounts and amounts. There is also a reordered header with an unknown column and a Windows-1250 encoded CSV.

The camt fixtures cover a camt.053.001.02 statement with an incoming payment carrying its symbols in the creditor reference, an outgoing cross-currency payment with charges and a German counter-account, a reversal with a return reason, and a pending card payment with no NtryDtls at all; a camt.053.001.08 statement with Pty-wrapped parties, a three-payment batch, symbols in proprietary references, a domestic account without an IBAN, an overdrawn closing balance and charge records to sum; and a camt.052 intraday report. The balances add up with the entries in each of them.

The MT940 fixtures are a MultiCash file with SWIFT block headers, two messages, structured field 86 including a name split over two subfields, a funds code and a charge; and a message with an overdrawn opening balance, a RC reversal, an entry date that crosses New Year and an unstructured field 86.

Sources

Credits

The knowledge of the ABO field layout originates from jakubzapletal/bank-statements and its fork ifm24/bank-statements, both MIT licensed. This library is a fresh implementation written against the bank specifications, with no dependency on symfony/dom-crawler, immutable value objects and explicit bank dialects.

License

MIT — see LICENSE.