npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

sepa-xml-ts

v1.1.0

Published

Type-safe SEPA payment file library for pain.001 credit transfers and pain.008 direct debits. Parse, write, and validate ISO 20022 XML with output verified against the official ISO 20022 XSD.

Readme

sepa-xml-ts

Type-safe SEPA payment files for TypeScript. Parse, write, and validate ISO 20022 pain.001 credit transfers and pain.008 direct debits behind a model that abstracts the XML, with every generated file validated against the official EPC/ISO 20022 XSD in CI.

npm CI License: MIT provenance types install size codecov tests property-tested

Status: stable. 1.0.0 is published with a frozen public API and semver guarantees. Output is verified against the official ISO 20022 XSD in CI.

Table of contents

What it covers

What you can do today, what is planned, and what is deliberately out of scope. The library is a payment-file library: it produces, reads, and validates ISO 20022 SEPA XML. It does not talk to banks.

| Use case | Standard / format | Status | |---|---|---| | Write credit transfers to SEPA XML | pain.001.001.09 | ✅ Supported | | Write direct debits to SEPA XML | pain.008.001.08 | ✅ Supported | | Write German DK credit transfers | pain.001.003.03 | ✅ Supported | | Write German DK direct debits | pain.008.003.02 | ✅ Supported | | Write legacy ISO credit transfers | pain.001.001.03 | ✅ Supported | | Parse SEPA XML back to a typed model (auto-detects message type) | pain.001 / pain.008 | ✅ Supported | | Read older coexistence direct debits | pain.008.001.02 | ✅ Supported (read-only) | | Validate business rules (IBAN mod-97, EPC charset, exact CtrlSum, dates) | all | ✅ Supported | | Per-transaction amount cap and floor (EPC AT-06: 0.01 to 999,999,999.99 EUR) | all | ✅ Supported | | Identifier slash rules (MsgId / PmtInfId / EndToEndId: no leading/trailing /, no //) | all | ✅ Supported | | German Creditor Identifier length check (DE = exactly 18 chars) | direct debit | ✅ Supported | | Validate XML against the official ISO 20022 / EPC XSD | all 6 schemas | ✅ Supported | | SEPA Creditor Identifier check digits (ISO 7064 MOD 97-10) | direct debit | ✅ Supported | | Bank profiles: extra rules plus minor output tweaks (e.g. requireBic, ibanBicCountryMatch, batchBooking) | overlay | ✅ Supported | | pain.008 B2B specifics and sequence-type cross-field checks (R1/R2/R3/R4) | pain.008 | ✅ Supported | | Structured creditor/debtor postal address (PstlAdr) | all write variants (DK variants: Ctry + AdrLine only) | ✅ Supported | | Ultimate creditor/debtor at transaction level (UltmtCdtr / UltmtDbtr, name + optional structured id OrgId/PrvtId) | pain.001.001.09 / pain.008.001.08 | ✅ Supported | | Structured remittance / creditor reference (RmtInf/Strd/CdtrRefInf, conditional ISO 11649) | pain.001.001.09 / pain.008.001.08 | ✅ Supported | | Purpose and category purpose codes (Purp / CtgyPurp, ISO external codes, not list-validated) | pain.001.001.09 / pain.008.001.08 | ✅ Supported | | SDD mandate amendment (AmdmntInd + AmdmntInfDtls, incl. SMNDA, minimal fields) | pain.008.001.08 | ✅ Supported | | Further national write variants (e.g. Swiss .ch) | national pain.001 / pain.008 | 🟡 On request | | Additional named bank profiles | overlay | 🟡 On request | | Payment status reports | pain.002 | ⛔ Out of scope | | Account statements and reports | camt.05x | ⛔ Out of scope | | Bank connectivity and file transmission | EBICS, FinTS/HBCI, Peppol | ⛔ Out of scope | | Legacy pre-SEPA and SWIFT formats | DTAUS, SWIFT MT (MT103, MT940) | ⛔ Out of scope (deprecated) | | Non-EUR or non-SEPA payment schemes | | ⛔ Out of scope |

Legend: ✅ available now, 🟡 planned or available on request, ⛔ not covered. Roadmap items are demand-driven: a national variant only ships alongside that schema's official XSD and golden samples, because a wrong flavor is worse than none.

Note on structured address: the EPC makes a structured PstlAdr (separate town, postcode, country elements) mandatory for the modern messages from 15 November 2026, and many banks reject unstructured addresses already. The optional address field on each party is emitted as a structured PstlAdr for every write variant. The modern messages (pain.001.001.09, pain.008.001.08) and the legacy pain.001.001.03 (PostalAddress6) carry the full field set. The German DK variants (pain.001.003.03, pain.008.003.02) use the restricted PostalAddressSEPA type, which supports only country and up to two addressLines: any other field throws a clear error rather than being dropped silently.

Why this exists

A subtly wrong payments file is worse than no library. So correctness is the whole product, and it is enforced, not hoped for:

  • Money cannot float. Amounts are integer minor units (bigint), never JS number arithmetic.
  • CtrlSum is exact. The control sum equals the sum of transfers with zero rounding tolerance.
  • SEPA character set enforced (EPC217-08), as a concern separate from XML escaping.
  • IBANs validated by mod-97, not just a regex.
  • SEPA Creditor Identifier check digits validated (ISO 7064 MOD 97-10, with the business code excluded per EPC262-08). A creditor id that passes a regex but fails the check digit is caught before a file is ever emitted.
  • Dates are dates, never timezone-stamped datetimes.
  • The model is anchored on the official XSD. The test suite generates thousands of random valid models and asserts every serialized file validates against that XSD, and that every file parses back into the exact model it came from.
  • The parse path is fuzz-hardened. It never throws: malformed input, entity injections, and DTD/DOCTYPE payloads all return a typed failure rather than raising an exception.

How it compares

The table below is based on publicly available information and our own differential test suite (see test/differential.test.ts). We run sepa.js and sepa-xml-ts against the same inputs and compare their semantic output to verify compatible behavior.

| Feature | sepa-xml-ts | sepa (npm sepa, JS) | sepa_king (Ruby gem) | |---|---|---|---| | TypeScript types (built-in .d.ts, Zod model) | Yes | No (plain JS) | No (Ruby) | | Write pain.001 credit transfers | Yes (.09, .03, DK .003.03) | Yes (.03 only) | Yes (.03, .001.03) | | Write pain.008 direct debits | Yes (.08, DK .003.02) | No | Yes (.03.02) | | Parse SEPA XML back to typed model | Yes (auto-detects message type) | No | No | | Validate against the official ISO 20022 XSD | Yes (all 6 schemas, WASM) | No | No | | Business rule validation (IBAN mod-97, EPC charset, exact CtrlSum) | Yes | Partial | Partial | | Integer-only money (no float arithmetic) | Yes (bigint) | No (floats) | Yes (integer cents) | | SEPA Creditor Identifier check digits (ISO 7064 MOD 97-10) | Yes | No | No | | National variants (German DK pain.001.003.03 / pain.008.003.02) | Yes (with official XSD) | No | No | | Bank profiles (overlay rules, e.g. requireBic) | Yes | No | No | | Property-based + fuzz testing (fast-check, 200+ runs per suite) | Yes | No | No | | npm provenance (OIDC Trusted Publishing, build attestation) | Yes | No | No |

sepa.js (npm package sepa, by philipp kewisch) is a solid pain.001.001.03 generator with a simple API. sepa_king (Ruby) is a well-maintained pain.001/pain.008 library in the Ruby ecosystem. Neither includes TypeScript types, parse/round-trip, or XSD validation as first-class features.

The model, not the XML

You work with a model that reads the way you think about a payment, not the way the XSD nests its elements. A Money value instead of raw cents. A debtor or creditor is one AccountParty (name, IBAN, optional BIC), not three sibling elements. A document is a few batches, each a debit from one account on one date, each holding transfers. The library maps that to and from valid pain.001 XML for you, and derives NbOfTxs and CtrlSum so you never compute them by hand.

import { euros, type CreditTransferDocument } from "sepa-xml-ts";

const doc: CreditTransferDocument = {
  messageId: "MSG-2026-0001",
  createdAt: "2026-06-01T10:30:00Z", // ISO datetime (GrpHdr/CreDtTm)
  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",
        },
      ],
    },
  ],
};

Install

npm install sepa-xml-ts
# or: pnpm add sepa-xml-ts

ESM-only, ships its own type declarations. Node 18+.

Every release is published to npm with provenance via OIDC Trusted Publishing, so you can verify the build attestation on the npm package page.

Write

writeCreditTransfer validates the model, computes NbOfTxs and CtrlSum with exact integer arithmetic, and returns a pain.001.001.09 XML string. It cannot emit a structurally invalid file.

import { euros, writeCreditTransfer, validateCreditTransfer } from "sepa-xml-ts";

// validateCreditTransfer() returns a typed result instead of throwing
const result = validateCreditTransfer(doc);
if (!result.ok) {
  console.error(result.errors);
} else {
  const xml = writeCreditTransfer(result.data);
  console.log(xml); // <Document xmlns="urn:iso:std:iso:20022:tech:xsd:pain.001.001.09">...
}

Parse

parse turns SEPA XML back into a model, reconstructing Money from the formatted amount. It auto-detects the message type and returns a discriminated union, and never throws on malformed input.

import { parse, MessageType } from "sepa-xml-ts";

const parsed = parse(xml);
if (!parsed.ok) {
  console.error(parsed.error);
} else if (parsed.type === MessageType.CreditTransfer) {
  // 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 === MessageType.DirectDebit -> parsed.data is a DirectDebitDocument
  console.log("collections:", parsed.data.batches.flatMap((b) => b.collections).length);
}

MessageType.CreditTransfer is "pain.001" and MessageType.DirectDebit is "pain.008". Raw string comparisons still type-check: parsed.type === "pain.001" is identical in behaviour and the types are fully compatible.

The round-trip is anchored on the model: for any valid model, parse(write(model)) deep-equals the original. This is verified as a property test over thousands of generated inputs, for both message types.

Direct debit (pain.008)

In a direct debit scheme, one creditor (e.g. a subscription service or utility) collects money directly from many debtors (customers), each of whom has signed a mandate authorizing future collections. Unlike a credit transfer (where the payer pushes funds), a direct debit pulls funds on the agreed collection date. The creditor holds the mandate and presents it with every collection; the debtor's bank verifies the mandate and debits the account. The SEPA Direct Debit (SDD) Core scheme is the standard used across the SEPA area.

writeDirectDebit emits valid pain.008.001.08 XML (deriving NbOfTxs, CtrlSum, and fanning the creditor and its SEPA Creditor Identifier into each batch for you).

import { euros, writeDirectDebit, type DirectDebitDocument } from "sepa-xml-ts";

const doc: DirectDebitDocument = {
  messageId: "DD-2026-0001",
  createdAt: "2026-06-01T09:00:00Z",
  initiatingParty: "ACME GmbH",
  creditor: {
    name: "ACME GmbH",
    iban: "DE89370400440532013000",
    bic: "COBADEFFXXX",
    creditorId: "DE98ZZZ09999999999", // SEPA Creditor Identifier
  },
  batches: [
    {
      id: "BATCH-001",
      collectionDate: "2026-06-10", // a date
      sequenceType: "FRST", // FRST | RCUR | OOFF | FNAL
      localInstrument: "CORE", // CORE | B2B (defaults to CORE)
      collections: [
        {
          endToEndId: "SUB-1001",
          amount: euros("49.99"),
          debtor: { name: "Kunde Eins", iban: "NL91ABNA0417164300" },
          mandate: { id: "MND-001", signatureDate: "2026-01-15" },
          remittanceInfo: "Subscription June",
        },
      ],
    },
  ],
};

const xml = writeDirectDebit(doc);

Note: localInstrument defaults to CORE when omitted.

validateDirectDebit and writeDirectDebit enforce four cross-field mandate rules from the SEPA rulebook. R1: mandate.signatureDate must not be after the batch collectionDate (equal dates are allowed). R2: a mandate id used in any OOFF batch must appear in exactly one collection across the whole document. R3: a mandate id must not appear under both CORE and B2B local instruments in the same document. R4: if any collection in a batch has mandate.amendment.sameMandateNewDebtorAccount === true (SMNDA, "same mandate, new debtor account at the same bank"), that batch's sequenceType must be FRST.

Mandate amendment (SMNDA)

When a debtor's bank account changes at the same bank (Same Mandate New Debtor Account), set mandate.amendment.sameMandateNewDebtorAccount: true and place the collection in a FRST batch (required by R4 and enforced by the writer):

import { euros, writeDirectDebit, type DirectDebitDocument } from "sepa-xml-ts";

const doc: DirectDebitDocument = {
  messageId: "DD-AMEND-001",
  createdAt: "2026-06-01T09:00:00Z",
  initiatingParty: "ACME GmbH",
  creditor: {
    name: "ACME GmbH",
    iban: "DE89370400440532013000",
    bic: "COBADEFFXXX",
    creditorId: "DE98ZZZ09999999999",
  },
  batches: [
    {
      id: "BATCH-SMNDA",
      collectionDate: "2026-06-20",
      sequenceType: "FRST", // R4: SMNDA requires FRST
      collections: [
        {
          endToEndId: "AMEND-001",
          amount: euros("49.99"),
          debtor: { name: "Kunde Eins", iban: "DE75512108001245126199" },
          mandate: {
            id: "MND-001",
            signatureDate: "2026-01-15",
            amendment: {
              // Debtor opened a new account at the same bank; old account not disclosed
              sameMandateNewDebtorAccount: true,
            },
          },
        },
      ],
    },
  ],
};

const xml = writeDirectDebit(doc);
// Emits AmdmntInd=true + OrgnlDbtrAgt/FinInstnId/Othr/Id=SMNDA

For other amendment scenarios (original mandate id change, original creditor scheme id, original debtor account, original debtor agent BIC, original frequency, original final collection date, original reason), set the corresponding optional fields on the amendment object.

Money

Money is a first-class value, never a bare number. Construct it from a decimal string so a float can never sneak in:

import { euros, formatMoney } from "sepa-xml-ts";

euros("123.45"); // { currencyCode: "EUR", minorUnits: 12345n }
euros("0.01"); // { currencyCode: "EUR", minorUnits: 1n }
euros("123.4"); // .4 is padded to .40 -> 12340n
euros("1.234"); // throws: more than 2 decimal places
euros("-1.00"); // throws: negative

formatMoney(euros("123.45")); // "123.45" (always 2 decimals, dot, no grouping)

Advanced fields

Structured remittance, ultimate parties, postal address, and purpose codes are all optional additive fields on Transfer (pain.001) and Collection (pain.008). The snippet below shows all of them together on a credit transfer; the shapes are identical for direct debit collections.

import {
  euros,
  writeCreditTransfer,
  type CreditTransferDocument,
  type Transfer,
} from "sepa-xml-ts";

const transfer: Transfer = {
  endToEndId: "INV-2001",
  amount: euros("250.00"),
  creditor: {
    name: "Beispiel AG",
    iban: "NL91ABNA0417164300",
    // Optional structured postal address (mandatory from EPC 2026-11-22)
    address: {
      streetName: "Hauptstrasse",
      buildingNumber: "1",
      postCode: "10115",
      townName: "Berlin",
      country: "DE",
    },
  },
  // Ultimate creditor: the party who ultimately receives the funds (e.g. a factor)
  ultimateCreditor: {
    name: "Factor GmbH",
    // Optional structured id: OrgId XOR PrvtId
    id: { organisationId: { other: { id: "FACTOR-001" } } },
  },
  // Ultimate debtor: the party on whose behalf the payment is made
  ultimateDebtor: { name: "End Customer" },
  // Structured remittance (mutually exclusive with remittanceInfo)
  structuredRemittance: {
    creditorReference: "RF18539007547034", // ISO 11649 RF reference: check digits validated
    referenceType: "SCOR", // DocumentType3Code enum (RADM/RPIN/FXDR/DISP/PUOR/SCOR)
    // referredDocuments and referredDocumentAmount are also supported (see types)
  },
  // Purpose code: ISO external code (SALA, SUPP, etc.), 1-4 chars, not enum-validated
  purpose: "SUPP",
};

const doc: CreditTransferDocument = {
  messageId: "MSG-ADV-001",
  createdAt: "2026-06-01T10:30:00Z",
  initiatingParty: "ACME GmbH",
  batches: [
    {
      id: "BATCH-ADV-001",
      executionDate: "2026-06-03",
      debtor: { name: "ACME GmbH", iban: "DE89370400440532013000" },
      categoryPurpose: "SUPP", // batch-level category purpose (PmtTpInf/CtgyPurp)
      transfers: [transfer],
    },
  ],
};

const xml = writeCreditTransfer(doc);

Notes:

  • structuredRemittance and remittanceInfo are mutually exclusive on the same transaction.
  • ultimateCreditor, ultimateDebtor, structured remittance, and purpose codes are supported for pain.001.001.09 and pain.008.001.08 only. Legacy and DK variants throw if present.
  • address is emitted as PostalAddress24 for the modern ISO variants and as PostalAddress6 for pain.001.001.03. DK variants support only country and up to two addressLines.

Bank profiles

A bank profile is an overlay: extra validation rules and optional minor output tweaks that layer on top of the always-correct SEPA core. The model stays clean; the profile captures what a specific bank requires beyond the XSD.

What a profile is (and is not)

A profile is additive, not a replacement. It runs after base Zod validation, and it must never make the output XSD-invalid. The output options that a profile may set (e.g. batchBooking) are limited to elements the XSD already permits.

A profile is not a different message schema. Named national write-variant profiles (different output schema, e.g. German pain.001.003.03 for DK/CAMT-DE) are a separate mechanism and only ship alongside that schema's official XSD and golden test samples. A wrong flavor is worse than none; do not use a profile to change the message type.

The requireBic profile

Some banks reject IBAN-only files even though the SEPA XSD and the post-2016 SEPA rulebook make BIC optional. The requireBic profile surfaces that rejection at validation time, before submission.

import {
  writeCreditTransfer,
  validateCreditTransfer,
  requireBic,
} from "sepa-xml-ts";

// Validation: base Zod rules + profile rules, merged into one result
const result = validateCreditTransfer(doc, { profile: requireBic });
if (!result.ok) {
  // result.errors: Zod issues (schema)
  // result.profileIssues: bank-profile issues (e.g. missing BIC)
  console.error(result.errors, result.profileIssues);
}

// Writing: throws if either base validation or the profile check fails
const xml = writeCreditTransfer(doc, { profile: requireBic });

Same API for direct debit:

import { writeDirectDebit, validateDirectDebit, requireBic } from "sepa-xml-ts";

const result = validateDirectDebit(doc, { profile: requireBic });
const xml = writeDirectDebit(doc, { profile: requireBic });

The ibanBicCountryMatch profile

When a party has both an IBAN and a BIC, their country codes should normally match (e.g. a DE IBAN paired with a BIC whose country code is also DE). A mismatch can indicate a data-entry error. The ibanBicCountryMatch profile checks this for every party that has both.

This check is deliberately opt-in rather than a core rule. Several territories use a different IBAN country code from the BIC country code for legitimate historical reasons: French overseas departments and collectivities (GP, GF, MQ, RE, YT, PM, BL, MF) use IBANs with their own country prefix but BICs registered under FR; British Crown Dependencies (GG, JE, IM) have their own IBAN prefixes but BICs under GB. Encoding this as a core rule without a complete and maintained exception table would risk false positives on valid files. The profile ships with a documented exception table and you opt in when you know your bank performs this check.

import {
  writeCreditTransfer,
  validateCreditTransfer,
  ibanBicCountryMatch,
} from "sepa-xml-ts";

const result = validateCreditTransfer(doc, { profile: ibanBicCountryMatch });
if (!result.ok) {
  console.error(result.profileIssues); // e.g. "IBAN country (DE) does not match BIC country (NL)"
}

const xml = writeCreditTransfer(doc, { profile: ibanBicCountryMatch });

Output options: batchBooking

Profiles can also request minor output tweaks. The batchBooking option emits <BtchBookg>true</BtchBookg> (or false) in each PmtInf element (XSD position: after PmtMtd, before NbOfTxs). The output is still XSD-valid and the parser ignores the element so the round-trip is unaffected.

import { writeCreditTransfer, type BankProfile } from "sepa-xml-ts";

const myBankProfile: BankProfile = {
  id: "my-bank",
  output: { batchBooking: true },
};

const xml = writeCreditTransfer(doc, { profile: myBankProfile });
// <BtchBookg>true</BtchBookg> now appears in every PmtInf

Authoring your own profile

Implement the BankProfile interface. Return ProfileIssue[] from the check functions; return an empty array to indicate the document passes. Use dot-delimited path values to point at the offending field.

import type { BankProfile, ProfileIssue } from "sepa-xml-ts";

export const myProfile: BankProfile = {
  id: "my-bank-rules",
  description: "Extra rules required by My Bank AG",

  checkCreditTransfer(doc): ProfileIssue[] {
    const issues: ProfileIssue[] = [];
    for (const [bi, batch] of doc.batches.entries()) {
      if (batch.transfers.length > 100) {
        issues.push({
          path: `batches.${bi}.transfers`,
          message: "My Bank AG rejects batches with more than 100 transfers",
        });
      }
    }
    return issues;
  },
};

National variants

Some countries use national extensions of the SEPA schemas under different namespaces. These are distinct from bank profiles: a profile is additive on top of an existing schema, while a national variant is a different XML schema with its own element ordering and element names.

pain.001.001.03 (legacy ISO credit transfer)

The legacy ISO credit transfer schema pain.001.001.03 is supported as a write target for systems that have not yet migrated to the modern pain.001.001.09. Pass variant: 'pain.001.001.03' (or the CreditTransferVariant.SCT_Legacy constant) to emit the older namespace. The model input is the same CreditTransferDocument; only the serialization differs.

import { euros, writeCreditTransfer, CreditTransferVariant, type CreditTransferDocument } from "sepa-xml-ts";

const xml = writeCreditTransfer(doc, { variant: CreditTransferVariant.SCT_Legacy });
// equivalent to: writeCreditTransfer(doc, { variant: "pain.001.001.03" })
// <Document xmlns="urn:iso:std:iso:20022:tech:xsd:pain.001.001.03">...

Structural deltas from pain.001.001.09:

  • ReqdExctnDt is a plain ISODate value (no <Dt> child wrapper, unlike the .09 DateAndDateTime2Choice)
  • Agent elements use <BIC> element name (not <BICFI>)
  • The debtor FinInstnId is always emitted at PmtInf level, even when debtor.bic is absent (an empty <FinInstnId/> is required by the .03 XSD)
  • Ultimate parties, structured remittance, and purpose codes are not supported; the writer throws if any are present (no silent data loss)

German DK variant: pain.001.003.03

The German DK (DFU agreement Anlage 3) uses the namespace urn:iso:std:iso:20022:tech:xsd:pain.001.003.03. Pass variant: 'pain.001.003.03' (or the CreditTransferVariant.SCT_DK constant) to emit and validate against this schema. The model input is the same CreditTransferDocument for both variants; only the serialization differs.

import {
  euros,
  writeCreditTransfer,
  CreditTransferVariant,
  type CreditTransferDocument,
} from "sepa-xml-ts";
import { validateXsd } from "sepa-xml-ts/xsd";

const xml = writeCreditTransfer(doc, { variant: CreditTransferVariant.SCT_DK });
// equivalent to: writeCreditTransfer(doc, { variant: "pain.001.003.03" })
// <Document xmlns="urn:iso:std:iso:20022:tech:xsd:pain.001.003.03">...

const xsdResult = await validateXsd(xml); // validates against the DK XSD
console.log(xsdResult.valid); // true

The DK structural differences from pain.001.001.09:

  • ReqdExctnDt is a plain ISODate value (no <Dt> child wrapper)
  • FinInstnId uses <BIC> element name (not <BICFI>)
  • DbtrAgt is required at PmtInf level: when debtor.bic is absent, the writer emits <Othr><Id>NOTPROVIDED</Id></Othr> (the only allowed fallback in the DK XSD)
  • CdtrAgt is optional at transaction level: omitted when creditor.bic is absent

parse() also reads pain.001.003.03 into a CreditTransferDocument (type "pain.001"), and validateXsd() uses the vendored DK XSD as the correctness oracle. Both are verified in CI against the official DK XSD.

The variant and profile options can be combined:

const xml = writeCreditTransfer(doc, {
  variant: "pain.001.003.03",
  profile: { id: "my-bank", output: { batchBooking: true } },
});

German DK variant: pain.008.003.02

The German DK direct debit variant uses the namespace urn:iso:std:iso:20022:tech:xsd:pain.008.003.02. Pass variant: 'pain.008.003.02' (or the DirectDebitVariant.SDD_DK constant) to emit and validate against this schema. The model input is the same DirectDebitDocument for both variants; only the serialization differs.

import {
  euros,
  writeDirectDebit,
  DirectDebitVariant,
  type DirectDebitDocument,
} from "sepa-xml-ts";
import { validateXsd } from "sepa-xml-ts/xsd";

const xml = writeDirectDebit(doc, { variant: DirectDebitVariant.SDD_DK });
// equivalent to: writeDirectDebit(doc, { variant: "pain.008.003.02" })
// <Document xmlns="urn:iso:std:iso:20022:tech:xsd:pain.008.003.02">...

const xsdResult = await validateXsd(xml); // validates against the DK SDD XSD
console.log(xsdResult.valid); // true

The DK SDD structural differences from pain.008.001.08:

  • FinInstnId uses <BIC> element name (not <BICFI>)
  • CdtrAgt at PmtInf level: when creditor.bic is absent, the writer emits <Othr><Id>NOTPROVIDED</Id></Othr> (the only allowed fallback in the DK XSD)
  • DbtrAgt at transaction level: same NOTPROVIDED fallback when debtor.bic is absent
  • GrpHdr omits CtrlSum (optional in the DK XSD; reference sample omits it)

parse() reads pain.008.003.02 into a DirectDebitDocument (type "pain.008"), and validateXsd() uses the vendored DK XSD as the correctness oracle.

The variant and profile options can be combined:

const xml = writeDirectDebit(doc, {
  variant: "pain.008.003.02",
  profile: { id: "my-bank", output: { batchBooking: true } },
});

Validate against the official XSD (optional)

Business rules (charset, IBAN, CtrlSum, dates) are already enforced by the model and the writer. For belt-and-suspenders schema validation against the official EPC XSD, use the ./xsd subpath. It pulls a WASM blob, so it lives behind a separate entry point and is lazy-loaded: write-only users never download it.

import { validateXsd } from "sepa-xml-ts/xsd";

const xsdResult = await validateXsd(xml);
if (!xsdResult.valid) {
  console.error(xsdResult.errors);
}

API surface

From sepa-xml-ts:

| Export | Description | |---|---| | CreditTransferDocument, PaymentBatch, Transfer, AccountParty, Money | pain.001 model types | | PostalAddress, UltimateParty, PartyIdentification | Optional party/address types (pain.001 + pain.008) | | StructuredRemittance, ReferenceType, ReferredDocument, RemittanceAmount | Structured remittance types | | Purpose, CategoryPurpose | Purpose and category-purpose types | | DirectDebitDocument, DirectDebitBatch, Collection, Creditor, Mandate, MandateAmendment, SequenceType, LocalInstrument | pain.008 model types | | CreditTransferDocumentSchema, DirectDebitDocumentSchema | The Zod schemas (single source of truth) | | euros(amount: string): Money | Build a Money value safely | | formatMoney(m: Money): string | Format a Money value to "123.45" | | writeCreditTransfer(model, options?): string | Model to pain.001 XML (options.variant selects schema) | | writeDirectDebit(model, options?): string | Model to pain.008 XML (options.variant selects schema) | | parse(xml: string): ParseResult | SEPA XML to model, auto-detecting message type | | ParseResult, ParseSuccess001, ParseSuccess008, ParseFailure | Discriminated union from parse. ParseSuccess001 has type: "pain.001", ParseSuccess008 has type: "pain.008". | | validateCreditTransfer(input, options?): ValidationResult | Validate a credit-transfer model (schema + optional profile) | | validateDirectDebit(input, options?): DirectDebitValidationResult | Validate a direct-debit model (schema + rules R1-R4 + optional profile) | | ValidationResult, ValidationSuccess, ValidationFailure | Result types for validateCreditTransfer | | DirectDebitValidationResult, DirectDebitValidationSuccess, DirectDebitValidationFailure | Result types for validateDirectDebit (adds ruleIssues for R1-R4 violations) | | ValidateOptions | Options type for validateCreditTransfer / validateDirectDebit | | checkDirectDebitRules(doc): ProfileIssue[] | Run the R1-R4 cross-field rule checks standalone | | WriteCreditTransferOptions | Options type for writeCreditTransfer | | CreditTransferVariant | 'pain.001.001.09' \| 'pain.001.001.03' \| 'pain.001.003.03' | | WriteDirectDebitOptions | Options type for writeDirectDebit | | DirectDebitVariant | 'pain.008.001.08' \| 'pain.008.003.02' | | BankProfile, ProfileIssue | Types for authoring overlay bank profiles | | requireBic | Built-in profile: requires a BIC on every agent | | ibanBicCountryMatch | Built-in profile: opt-in IBAN vs BIC country consistency check |

From sepa-xml-ts/xsd:

| Export | Description | |---|---| | validateXsd(xml: string): Promise<XsdResult> | Validate XML against the official EPC XSD (all 6 supported schemas) |

Internal helpers (IBAN, SEPA charset, XML escaping) are intentionally not exported.

Scope

  • Supported write+read+XSD-validate: pain.001.001.09, pain.001.001.03 (legacy ISO CT), pain.001.003.03 (German DK CT variant), pain.008.001.08, and pain.008.003.02 (German DK SDD variant).
  • Read-only (coexistence): pain.008.001.02.
  • Out of scope: bank connectivity / transmission (EBICS, FinTS, Peppol). This is a file library.

License

MIT