No-float money
Amounts are bigint minor units, never JS number arithmetic. euros("123.45") gives
12345n. A float can never sneak into your CtrlSum.
Real code from the library. Model first, XML second.
import { euros, type CreditTransferDocument } from 'sepa-xml-ts'
const doc: CreditTransferDocument = { messageId: 'MSG-2026-0001', createdAt: '2026-06-01T10:30:00Z', initiatingParty: 'ACME GmbH', batches: [ { id: 'BATCH-001', executionDate: '2026-06-03', // a date, never a datetime debtor: { name: 'ACME GmbH', iban: 'DE89370400440532013000', bic: 'COBADEFFXXX', }, transfers: [ { endToEndId: 'INV-1001', amount: euros('123.45'), creditor: { name: 'Beispiel AG', iban: 'NL91ABNA0417164300' }, remittanceInfo: 'Invoice 1001', }, ], }, ],}import { writeCreditTransfer, validate } from 'sepa-xml-ts'
// validate() returns a typed result instead of throwingconst result = validate(doc)if (!result.ok) { console.error(result.errors)} else { const xml = writeCreditTransfer(result.data) // <Document xmlns="urn:iso:std:iso:20022:tech:xsd:pain.001.001.09">... // NbOfTxs and CtrlSum are derived with exact integer arithmetic.}import { parse } from 'sepa-xml-ts'
const parsed = parse(xml)if (!parsed.ok) { console.error(parsed.error)} else if (parsed.type === 'pain.001') { // parsed.data is a CreditTransferDocument const total = parsed.data.batches .flatMap((b) => b.transfers) .reduce((sum, t) => sum + t.amount.minorUnits, 0n) console.log('credit transfer total:', total)} else { // parsed.type === 'pain.008' -> parsed.data is a DirectDebitDocument console.log('collections:', parsed.data.batches.flatMap((b) => b.collections).length)}import { validateXsd } from 'sepa-xml-ts/xsd'
// Optional belt-and-suspenders check against the official EPC XSD.// Lazy-loads libxml2-wasm; write-only users never download it.const xsdResult = await validateXsd(xml)if (!xsdResult.valid) { console.error(xsdResult.errors)}No-float money
Amounts are bigint minor units, never JS number arithmetic. euros("123.45") gives
12345n. A float can never sneak into your CtrlSum.
Exact CtrlSum
The control sum is derived by the writer with exact integer addition across all transfers. Zero rounding tolerance.
EPC charset enforced
SEPA character set EPC217-08 is enforced as a concern separate from XML escaping. Non-SEPA characters are rejected, not silently dropped.
IBAN mod-97 check
Every IBAN is validated by mod-97 checksum, not just a regex. Invalid IBANs are caught before a file is ever written.
Dates, not datetimes
Execution and collection dates are plain YYYY-MM-DD values. The library never stamps a
timezone on a date-only field.
XSD-as-oracle testing
Every build generates hundreds of random valid models, writes them to XML, and asserts each file passes the official EPC XSD. The XSD is the ground truth.
Install the package.
npm install sepa-xml-ts# orpnpm add sepa-xml-tsESM-only, ships its own type declarations. Node 18+.
Build a document.
import { euros, type CreditTransferDocument } from 'sepa-xml-ts'
const doc: CreditTransferDocument = { messageId: 'MSG-001', createdAt: new Date().toISOString(), initiatingParty: 'My Company GmbH', batches: [ { id: 'BATCH-001', executionDate: '2026-06-10', debtor: { name: 'My Company GmbH', iban: 'DE89370400440532013000', }, transfers: [ { endToEndId: 'PAY-001', amount: euros('99.50'), creditor: { name: 'Supplier AG', iban: 'NL91ABNA0417164300' }, }, ], }, ],}Write and validate.
import { writeCreditTransfer, validate } from 'sepa-xml-ts'
const result = validate(doc)if (result.ok) { const xml = writeCreditTransfer(result.data) // xml is a valid pain.001.001.09 string, ready to submit}Quickstart
Build a credit transfer document, validate it, write it to XML, and parse it back in five minutes. Read the quickstart
GitHub
Source code, issues, and the test suite with XSD-oracle property tests. View on GitHub