Skip to content

Bank profiles

A bank profile is an overlay on the always-correct SEPA core, never a fork of it. Profiles let you express what a specific bank requires beyond the standard SEPA XSD: extra validation rules and optional minor output tweaks.

A BankProfile has two parts:

  1. Check functions (checkCreditTransfer, checkDirectDebit): return ProfileIssue[]. An empty array means the document passes.
  2. Output options (output): minor additive tweaks such as batchBooking. These options only set elements the XSD already permits. A profile can never make the output XSD-invalid.

A profile is additive. It runs after the base Zod validation, as a second pass. It never replaces or weakens any base rule.

A profile is not a different message schema. If you need a different XML namespace (e.g. German DK pain.001.003.03), use the variant option, not a profile. See National variants.

Some banks reject IBAN-only files even though the SEPA rulebook and the XSD make BIC optional since 2016. The built-in requireBic profile catches this at validation time.

import { writeCreditTransfer, validateCreditTransfer, requireBic } from 'sepa-xml-ts'
// Validate with the profile
const result = validateCreditTransfer(doc, { profile: requireBic })
if (!result.ok) {
console.error(result.errors) // base Zod issues
console.error(result.profileIssues) // requireBic issues (missing BIC)
}
// Write: throws if base validation or 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 })

When a party has both an IBAN and a BIC, the IBAN country (chars 1-2) and the BIC country (chars 5-6) normally match, and a mismatch often signals a data-entry error. The built-in ibanBicCountryMatch profile checks this for every party (debtor, creditor, and per-transaction parties) on both message types.

import { validateCreditTransfer, writeDirectDebit, ibanBicCountryMatch } from 'sepa-xml-ts'
const result = validateCreditTransfer(doc, { profile: ibanBicCountryMatch })
const xml = writeDirectDebit(ddDoc, { profile: ibanBicCountryMatch })

This is shipped as an opt-in profile, not a core rule, on purpose. Some legitimate cases have a mismatched IBAN and BIC country, for example French overseas territories (GP, GF, MQ, RE, YT, PM, BL, MF) that use FR-registered BICs, and the Channel Islands / Isle of Man that use GB BICs. The profile carries a documented exception table for these. Encoding this as a core rule would risk rejecting valid files, and a false positive is worse than no check, so you enable it only when you know your bank performs the same check.

Profiles can request minor output tweaks via output. The batchBooking option emits <BtchBookg>true</BtchBookg> (or false) in each PmtInf element, at the XSD-correct position after PmtMtd and before NbOfTxs. The output is XSD-valid. 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 })
// Each PmtInf now contains: <BtchBookg>true</BtchBookg>

Implement the BankProfile interface. Check functions receive the validated model and return ProfileIssue[]. 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',
})
}
for (const [ti, transfer] of batch.transfers.entries()) {
if (!transfer.creditor.bic) {
issues.push({
path: `batches.${bi}.transfers.${ti}.creditor.bic`,
message: 'My Bank AG requires BIC on all creditor accounts',
})
}
}
}
return issues
},
checkDirectDebit(doc): ProfileIssue[] {
// Return [] if no additional rules apply to direct debits
return []
},
}

The profile and variant options are independent and can be combined:

const xml = writeCreditTransfer(doc, {
variant: 'pain.001.003.03',
profile: requireBic,
})

The profile runs against the validated model. The variant controls the XML serialization. Both can be active at the same time.