@fin.cx/sepa

SEPA credit transfer files as German banks take them: pain.001.001.09 after the rules of the Deutsche Kreditwirtschaft (DK) in Anlage 3 des DFÜ-Abkommens, valid against the DK's schema pain.001.001.09_GBIC_5. Any batch of transfers from one account goes to the bank as one file, which the person uploads to their online banking or sends over EBICS: a supplier run, expense reimbursements, a tax payment, a payroll's net pay. The purpose of each transfer is the caller's choice of ISO 20022 code.

It also reads such files, pain.001.001.09 and the older pain.001.001.03: a file another program wrote, such as the payment list of a payroll program, comes back as its blocks and payments, with every value checked.

The package is pure: it reads no clock, keeps no state and writes nothing anywhere. The file goes back to the caller as text and as UTF-8 bytes.

Issue Reporting and Security

For reporting bugs, issues, or security vulnerabilities, please visit community.foss.global/. This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a code.foss.global/ account to submit Pull Requests directly.

Install

pnpm install @fin.cx/sepa

Usage

import { writeCreditTransfer, SepaError } from '@fin.cx/sepa';

const file = writeCreditTransfer({
  messageId: 'ACME-2026-03-27-1', // new for every file: a bank refuses a second file with the same id
  createdAt: new Date(),
  debtor: { name: 'Acme GmbH', iban: 'DE89 3704 0044 0532 0130 00' }, // the BIC is optional
  executionDate: '2026-03-27',
  payments: [
    {
      endToEndId: 'INV-2026-0815', // travels to the creditor and comes back with a return
      amountCents: 119_000,
      creditor: { name: 'Bürobedarf Beispiel GmbH', iban: 'DE02120300000000202051' },
      remittance: { unstructured: 'Rechnung 2026-0815' },
    },
    {
      endToEndId: 'EXP-2026-03-0007',
      amountCents: 4_350,
      creditor: { name: 'Jonas Müller', iban: 'DE44500105175407324931' },
      remittance: { unstructured: 'Auslagen 03/2026' },
    },
  ],
});

file.xml; // '<?xml version="1.0" encoding="UTF-8"?>\n<Document xmlns="urn:iso:std:iso:20022:tech:xsd:pain.001.001.09">…'
file.bytes; // the same, UTF-8 without a byte order mark
file.controlSumCents; // 123350
file.numberOfTransactions; // 2

Codes are the caller's choice. A payroll's net pay, for example, names its category for the debtor's bank and each payment's purpose for the creditor's bank:

const payroll = writeCreditTransfer({
  messageId: 'ACME-PAYROLL-2026-03',
  createdAt: new Date(),
  debtor: { name: 'Acme GmbH', iban: 'DE89370400440532013000' },
  executionDate: '2026-03-27',
  categoryPurpose: 'SALA', // for the debtor's bank: a payroll
  payments: [
    {
      endToEndId: 'PAYROLL-2026-03-0001',
      amountCents: 298_017,
      creditor: { name: 'Jonas Müller', iban: 'DE44500105175407324931' },
      purpose: 'SALA', // the employee's bank books it as wages
      remittance: { unstructured: 'Lohn/Gehalt 03/2026' },
    },
    {
      endToEndId: 'PAYROLL-2026-03-0002',
      amountCents: 4_000,
      creditor: { name: 'Bausparkasse Beispiel AG', iban: 'DE02120300000000202051' },
      ultimateCreditorName: 'Jonas Müller',
      purpose: 'CBFF', // vermögenswirksame Leistungen
      remittance: { unstructured: '006/VL-4711' }, // "XXJ/contract number", as the DK asks for VL
    },
  ],
});

What the file holds

  • One group header and one payment information block: one debtor account, one execution day, payment method TRF, service level SEPA, charges SLEV, batch booking unless batchBooking: false (which a bank grants only by agreement).
  • The number of transactions and the control sum in both, the amounts in euros with two decimals.
  • The debtor's bank by its BIC, or NOTPROVIDED where none is given; the creditor's bank by its BIC where one is given (the IBAN alone routes a SEPA payment).
  • The category purpose of the file (PmtTpInf/CtgyPurp), stated once at the payment information level as the DK recommends. It is not shown on statements.
  • Per payment: the end-to-end id, the amount, the creditor with an optional postal address, the ultimate creditor's name, the purpose code (Purp) and either an unstructured remittance text of up to 140 characters or an ISO 11649 creditor reference (RF…, checked through @fin.cx/calculation).

The purpose code is what the creditor's bank shows. The DK's mapping of business transaction codes (Anlage 3, Anhang 1) books SALA, BONU, PENS, PAYR and SPSP as code 153 (wages, salaries, pensions), CBFF as 154 (vermögenswirksame Leistungen) and GOVT, SSBE and BENE as 156 (public treasuries). Codes are checked to be four capital letters; whether a code is on the ISO 20022 external code list is not checked.

What is refused

Every value is checked before anything is written. The first one a German bank or the DK's schema would refuse throws a SepaError with a code and the field it is about (payments[2].creditor.iban):

Code Refused
invalid_identifier A message, payment information or end-to-end id that is empty, over 35 characters, outside A–Z a–z 0–9 + ? / - : ( ) . , ' and the blank, or that starts or ends with / or contains //
duplicate_end_to_end_id Two payments with the same end-to-end id (NOTPROVIDED may repeat)
invalid_text A name (70), remittance text (140) or address field (street 70, building number 16, post code 16, town 35, two lines of 70) that is empty, too long or outside the SEPA character set; a country that is not two capital letters
invalid_iban An IBAN that is not of the form or whose check digits do not hold (ISO 7064 MOD 97-10)
invalid_bic A BIC that is not 8 or 11 characters of its form
invalid_amount An amount that is not whole cents from 0.01 to 999 999 999.99 euros
invalid_date An execution day that is no calendar day YYYY-MM-DD; a creation time that is no date
invalid_code A purpose or category purpose that is not four capital letters
invalid_remittance A creditor reference that is not ISO 11649 with valid check digits
invalid_payments No payments, or more than 9 999 999
invalid_document Reading only: see Reading a file
invalid_totals Reading only: see Reading a file

IBANs and BICs are taken as typed or printed. Blanks are removed and letters put in capitals; nothing else is changed.

The SEPA character set

German banks take the "Latin character set" of the EPC: the digits, A–Z, a–z, ' : ? , - ( + . ) / and the blank. They also take what the DK added: Ä Ö Ü ä ö ü ß & * $ % (Anlage 3 V3.9, chapter 2.1). isSepaText and nonSepaCharactersOf test a text against this set.

A name like Antonín Dvořák is refused rather than changed. toSepaText brings a text into the set and reports every character it changed, so the caller decides whether the result may be sent:

import { toSepaText } from '@fin.cx/sepa';

toSepaText('Antonín Dvořák – „Ærø“');
// { text: "Antonin Dvorak - 'AEro'", replaced: [{ offset: 5, character: 'í', replacement: 'i' }, …] }

The conversion rules are:

  • a letter with a diacritic becomes the letter without it;
  • Æ Œ Ø Ł Đ Ð Þ ẞ ı and their lower-case forms become AE OE O L D D TH SS i;
  • typographic quotes become ' and dashes become -;
  • white space becomes a blank;
  • anything else becomes ., the replacement the DK gives for *, $ and % where a bank cannot show them.

These are this package's rules, not the EPC's conversion table.

Reading a file

readCreditTransfer takes a file as text or as bytes and returns what it holds:

import { readCreditTransfer } from '@fin.cx/sepa';

const document = readCreditTransfer(uploadedBytes); // a string or a Uint8Array

document.version; // 'pain.001.001.09' or 'pain.001.001.03'
document.messageId; // 'ACME-PAYROLL-2026-03'
document.createdAt; // '2026-03-24T09:30:00.000Z', as written
document.controlSumCents; // 814362
for (const block of document.paymentInformations) {
  block.debtor; // { name: 'Acme GmbH', iban: 'DE89370400440532013000' }
  block.executionDate; // '2026-03-27'
  block.categoryPurpose; // 'SALA'
  for (const payment of block.payments) {
    payment.endToEndId; // 'PAYROLL-2026-03-0002'
    payment.amountCents; // 298017
    payment.creditor; // { name: 'Jonas Müller', iban: 'DE44500105175407324931', bic: 'INGADEFFXXX' }
    payment.purpose; // 'SALA'
    payment.remittance; // { unstructured: 'Lohn/Gehalt 03.2026 Müller & Co' }
  }
}

A payment has the shape writeCreditTransfer takes for one, without a postal address. It may also carry what a file names beside it: an instruction id, its own category purpose and its local instrument. A block may carry an execution time, its batch booking and its local instrument, such as INST for instant credit transfers.

A file is read as the DK's schemas pain.001.001.03_GBIC_3 and pain.001.001.09_GBIC_5 describe it. Within those rules the reader takes what a program may write:

  • any namespace prefix;
  • comments, processing instructions and xsi:schemaLocation;
  • a byte order mark;
  • white space around numbers, dates and booleans where XML Schema collapses it.

Values are checked as the writer checks them:

  • identifiers, codes, amounts in euros and BICs;
  • IBANs and creditor references with their check digits;
  • the numbers of transactions and the control sums against the payments.

Names and remittance texts are returned as written, in any character, within their lengths. IBANs and creditor references are returned in their electronic form.

A file is refused with a SepaError, whose field is a path into the result (paymentInformations[0].payments[2].creditor.iban), or empty for a file refused as a whole. Besides the codes above:

Code Refused
invalid_document A file that is no UTF-8 text or declares another encoding; that is no well-formed XML or holds a document type declaration; that is neither message. Also a missing element the message requires, an element more often than allowed, and an element or attribute the schema does not admit in its place. Also a file that is no SEPA credit transfer: another payment method, service level or charge bearer.
invalid_totals A number of transactions or a control sum that is malformed or does not agree with the payments

Not read, though a file may hold them: postal addresses, the parties' organisation and private ids, ultimate debtors, account proxies, the priority of a block, proprietary category purposes, and the order of the elements. Two payments with the same end-to-end id are read, not refused.

Other exports

  • isSepaIdentifier tests an id against the DK's identifier rules.
  • normalizeIban, isIban and normalizeBic give the electronic form of an IBAN or BIC as typed or printed, or null.
  • PAIN_001_NAMESPACE and PAIN_001_SCHEMA name the message and the DK schema a file is written for.
  • TSepaCreditTransferVersion, ISepaCreditTransferDocument, ISepaPaymentInformation and ISepaReadPayment type what readCreditTransfer returns.

Which format, and why

  • Anlage 3 V3.9 of the DFÜ-Abkommen (12 March 2025) took effect for SEPA on 5 October 2025. It specifies credit transfers as pain.001.001.09 (ISO 20022 version 2019) with the DK's schema pain.001.001.09_GBIC_5.xsd. That schema introduced the hybrid address and requires the currency euro.
  • Anlage 3 V26.11 (9 April 2026, in force from 15 November 2026) keeps the same message and schema for SEPA credit transfers.
  • Older versions: V3.9 names pain.001.001.03_GBIC_4 as the previous schema for SEPA credit transfers (chapter 2.4). A bank may still take it during a transition, by agreement. The DK's format life cycle (version 2.0 of 24 September 2026) lists the versions. This package writes only the current one. It reads pain.001.001.03 as well, after the DK's schema pain.001.001.03_GBIC_3 (Anlage 3 V3.3 to V3.6), for files from programs that still write it.

Postal addresses follow the hybrid rule of V3.9: the town and the country always, street, building number and post code where known, and at most two free address lines beside them. The DK recommends leaving the debtor's address out.

The tests validate every file against the DK's schema with libxml2 (xmllint-wasm). The schema itself checks less than the DK's rules. For example, it takes any four-character service level, while the writer always writes SEPA. The reader's tests read the DK's own examples for both versions, after validating them against their schemas.

The DK and SIZ GmbH reserve all rights to the schemas and examples, so they are not part of this repository:

  • test/dkdocuments.ts fetches them from ebics.de on the first run and caches them under .nogit/dk/;
  • it takes each only with the SHA-256 it had when it was read (2026-10-07 for the GBIC_5 schema, 2026-10-08 for the others);
  • the tests fail when a document cannot be fetched or has changed.

The DK's example for pain.001.001.03 carries the namespace of pain.001.001.09 over a body of pain.001.001.03 (its note says only the namespace changed). The reader refuses it as published, and the tests read it with the namespace of pain.001.001.03 restored.

Not covered

  • Writing SEPA instant credit transfers (LclInstrm INST, an execution time); the reader reads them. Direct debits (pain.008) and the payment status report (pain.002).
  • Writing more than one payment information block per file (one debtor account, one execution day per file); the reader reads any number.
  • Structured addresses with fields beyond street, building number, post code, town, country and two lines.
  • Organisation or private identifications of the parties, and ultimate debtors.
  • Whether an IBAN has its country's length and whether the country takes part in SEPA. Only the form and the check digits are checked.

Sources

  • DK, Anlage 3 des DFÜ-Abkommens "Spezifikation der Datenformate", V3.9 of 12 March 2025, chapter 2 (SEPA payment transactions) and 2.3 (simple types), and V26.11 of 9 April 2026 with its amendment history: ebics.de/de/datenformate
  • DK, Anhang 1 zu Anlage 3, Mapping GVC ► ISO BTC, V3.9: ebics.de/de/datenformate/ergaenzende-dokumente
  • DK, schema pain.001.001.09_GBIC_5.xsd in DK-TVS_SEPA_GBIC_5zzglISO_Originale.zip, SHA-256 d64da6e1553cf8dd38f98818fe3234fb79daec792d0a69483a1ab8e90dec8b8c: ebics.de/de/datenformate/ergaenzende-dokumente
  • DK, examples pain.001.001.09.xml (SHA-256 8a4dd9d04dee9286090cebc9263bad4b5ffdbfdfc8f21eccbe70c85d1c8df016) and pain.001.001.09Echtzeit.xml (SHA-256 296eb291397c6e5327a06167d65983a659f3d20966f3dd382c03ea56e2b027e8) in XML-Beispiele_SEPA.zip: ebics.de/de/datenformate/ergaenzende-dokumente
  • DK, schema pain.001.001.03_GBIC_3.xsd (SHA-256 4116195dd80c84821b82287dc96ad066a4adf5a54d74ae3f80c0873d7da6e97c) and example pain.001.001.03.xml (SHA-256 dbd77707100f98bb9f925a736075f71a987bf316c5d4bd8e22a2829e59ce5b9f) in Anlage3_V3.3-3.6_Beispiele_Schemadateien_GBIC3.zip: ebics.de/de/datenformate/archiv
  • DK, SEPA-Formatversionen im Verfahren "DFÜ mit Kunden", version 2.0 of 24 September 2026: ebics.de/de/datenformate/format-lifecycle

Unconfirmed:

  • That SALA is on the ISO 20022 list of category purpose codes rests on secondary sources: banks' implementation guides and the DK's own use of SALA as a purpose code. The ISO code set itself could not be fetched.
  • The EPC's character conversion table (EPC217-08) was not read.
  • The reader is tested against the DK's examples and against files this package writes, not yet against files that payroll or accounting programs export.

This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the license.md file.

Please note: The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.

Trademarks

This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.

Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.

Company Information

Task Venture Capital GmbH Registered at District Court Bremen HRB 35230 HB, Germany

For any legal inquiries or further information, please contact us via email at hello@task.vc.

By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.

S
Description
No description provided
Readme
351 KiB
Languages
TypeScript 100%