QR codes are a great way to quickly create a single bank payment. But what if you need to create multiple payments and regularly? There is a file format standard for that! Let’s look at how to build Sepa mass payment.
The goods news is that most banks support the format. In Slovakia, Slovenska sporitelna, Tatra Banka and CSOB were tested. Also the whole process is rather intuitive and straightforward. It only takes a couple of clicks.
The bad news is, that of course, the standard is xml. Granted, xml in itself is not a problem. However this format uses both arcane namespaces and terse element names. Still it could be worse, at least it’s not binary..
Given a list of payments, we need to generate an xml file to be imported into the bank’s internet-banking app.
We shall use typescript for the implementation.
Money transfers are nicely standardized, usually an IBAN code is all you need. An IBAN uniquely identifies a bank account, however some banks require the swift or bic code of the bank too. Since the IBAN already contains a bank identifier, we can easily lookup the swift code. There does not seem to be a free api to do this. The Swift organization promotes a product that looks like a universal dataset, but we haven’t tested it.
Another nice feature of IBANs is that they contain a verification code. If the user made a typo while entering an IBAN, it will not validate (well it is unlikely), thus money will not be sent to the wrong recipient.
Here is the code for iban validation.
function isValidIBANNumber(input: string) {
const CODE_LENGTHS = {
AD: 24, AE: 23, AT: 20, AZ: 28, BA: 20, BE: 16, BG: 22, BH: 22, BR: 29,
CH: 21, CR: 21, CY: 28, CZ: 24, DE: 22, DK: 18, DO: 28, EE: 20, ES: 24,
FI: 18, FO: 18, FR: 27, GB: 22, GI: 23, GL: 18, GR: 27, GT: 28, HR: 21,
HU: 28, IE: 22, IL: 23, IS: 26, IT: 27, JO: 30, KW: 30, KZ: 20, LB: 28,
LI: 21, LT: 20, LU: 20, LV: 21, MC: 27, MD: 24, ME: 22, MK: 19, MR: 27,
MT: 31, MU: 30, NL: 18, NO: 15, PK: 24, PL: 28, PS: 29, PT: 25, QA: 29,
RO: 24, RS: 22, SA: 24, SE: 24, SI: 19, SK: 24, SM: 27, TN: 24, TR: 26,
AL: 28, BY: 28, EG: 29, GE: 22, IQ: 23, LC: 32, SC: 31, ST: 25,
SV: 28, TL: 23, UA: 29, VA: 22, VG: 24, XK: 20
};
const iban = input.toUpperCase().replace(/[^A-Z0-9]/g, ''); // remove whitespace normalize to uppercase
const code = iban.match(/^([A-Z]{2})(\d{2})([A-Z\d]+)$/); // get the country and bank code
// check syntax and length
if (!code || iban.length !== CODE_LENGTHS[code[1]]) {
return false;
}
// rearrange country code and check digits, and convert chars to ints
const digits = (code[3] + code[1] + code[2]).replace(/[A-Z]/g, function (letter: string) {
return "" + (letter.charCodeAt(0) - 55);
});
// final check
return mod97(digits) === 1;
}
function mod97(s: string) {
let checksum = s.slice(0, 2);
for (let offset = 2; offset < s.length; offset += 7) {
const fragment = checksum + s.substring(offset, offset + 7);
checksum = parseInt(fragment, 10) % 97;
}
return checksum;
}
Here is a 2025 list of Slovak and Czech banks. For more european banks checkout the national central bank.
const get_bic_from_iban = (iban) => {
const map_sk = {
'0900': 'GIBASKBX',
'1100': 'TATRSKBX',
'0200': 'SUBASKBX',
'7500': 'CEKOSKBX',
'6500': 'POBNSKBA',
'5600': 'KOMASK2X',
'8360': 'BREXSKBX',
'8330': 'FIOZSKBA',
'1111': 'UNCRSKBX',
'8400': '',
'8420': 'BFKKSKBB',
'8130': 'CITISKBA',
'8050': 'COBASKBX',
'8170': 'KBSPSKBX',
'8160': 'EXSKSKBX',
'7300': 'INGBSKBX',
'8320': 'JTBPSKBA',
'8100': 'KOMBSKBA',
'0720': 'NBSBSKBX',
'8370': 'OBKLSKBA',
'5200': 'OTPVSKBX',
'8120': 'BSLOSK22',
'5900': 'PRVASKBA',
'3000': 'SLZBSKBA',
'3100': 'LUBASKBX',
'8180': 'SPSRSKBA',
'7930': 'WUSTSKBA',
'8430': 'KODBSKBX'
}
const map_cz = {
'0100': 'KOMBCZPP',
'0300': 'CEKOCZPP',
'0600': 'AGBACZPP',
'0710': 'CNBACZPP',
'0800': 'GIBACZPX',
'2010': 'FIOBCZPP',
'2020': 'BOTKCZPP',
'2060': 'CITFCZPP',
'2070': 'MPUBCZPP',
//'2100': 'Nemá SWIFT/BIC',
//'2200': 'Nemá SWIFT/BIC',
'2220': 'ARTTCZPP',
'2250': 'CTASCZ22',
//'2260': 'Nemá SWIFT/BIC',
//'2275': 'Nemá SWIFT/BIC',
'2600': 'CITICZPX',
'2700': 'BACXCZPP',
'3030': 'AIRACZPP',
'3050': 'BPPFCZP1',
'3060': 'BPKOCZPP',
'3500': 'INGBCZPP',
'4000': 'EXPNCZPP',
'4300': 'CMZRCZP1',
'5500': 'RZBCCZPP',
'5800': 'JTBPCZPP',
'6000': 'PMBPCZPP',
'6100': 'EQBKCZPP',
'6200': 'COBACZPX',
'6210': 'BREXCZPP',
'6300': 'GEBACZPP',
'6700': 'SUBACZPP',
'7910': 'DEUTCZPX',
// '7950': 'Nemá SWIFT/BIC',
// '7960': 'Nemá SWIFT/BIC',
// '7970': 'Nemá SWIFT/BIC',
// '7990': 'Nemá SWIFT/BIC',
'8030': 'GENOCZ21',
'8040': 'OBKLCZ2X',
// '8060': 'Nemá SWIFT/BIC',
'8090': 'CZEECZPP',
'8150': 'MIDLCZPP',
// '8190': 'Nemá SWIFT/BIC',
'8198': 'FFCSCZP1',
'8199': 'MOUSCZP2',
// '8200': 'Nemá SWIFT/BIC',
'8220': 'PAERCZP1',
// '8230': 'Nemá SWIFT/BIC',
// '8240': 'Nemá SWIFT/BIC',
'8250': 'BKCHCZPP',
'8255': 'COMMCZPP',
'8265': 'ICBKCZPP',
'8270': 'FAPOCZP1',
'8280': 'BEFKCZP1',
'8293': 'MRPSCZPP',
}
const valid = isValidIBANNumber(iban);
if (!valid) {
return "ERROR: Chybny iban";
}
let map: { [key: string]: string } = map_sk;
if (iban[0] === 'C' && iban[1] === 'Z')
map = map_cz;
const bank_code = iban.substring(4, 8);
const bic = map[bank_code];
if (!bic) {
return "ERROR :Neznama banka";//throw new Error("Neznama banka: " + iban);
}
return bic;
}
There are actually multiple versions of the mass-payment format. We shall look at version 03, which corresponds to the ISO 20022 format with the technical schema urn:iso:std:iso:20022:tech:xsd:pain.001.001.03
.
The file has 3 major parts
Each section needs an id, the current date is enough.
What info do we need for the header
Then for each transaction
We could use a proper dom and xml serializer. But, we shall cheat and generate the xml by concatenating text with substituted values. Don’t forget to escape the values, so we don’t generate a malformed xml.
interface IPayment {
vs: string;
ss: string;
ks: string;
amount: string;
iban: string;
bic: string;
name: string;
comment: string;
}
interface IPayer {
iban: string;
bic: string;
name: string;
}
const generateSepaXMLFile = (payer: IPayer, payments: IPayment[], payment_date: Date, total_amount: string) => {
const n = new Date();
const number_of_tx = payments.length;
const payment_date = payment_date.toISOString().substring(0, 10);
const msgid = n.toISOString().replace(/[-T:]/g, "");
const paymentid = msgid + "1";
const org_name = xmlEscape(payer.name);
const org_iban = payer.iban;
const org_bic = payer.bic || get_bic_from_iban(org_iban);
const prefix = `<?xml version="1.0" encoding="utf-8"?>
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:pain.001.001.03" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="urn:iso:std:iso:20022:tech:xsd:pain.001.001.03 pain.001.001.03.xsd">
<CstmrCdtTrfInitn>
<GrpHdr>
<MsgId>${msgid}</MsgId>
<CreDtTm>${n.toISOString().substring(0, 19)}</CreDtTm>
<NbOfTxs>${number_of_tx}</NbOfTxs>
<CtrlSum>${total_amount}</CtrlSum>
<InitgPty>
<Nm>${org_name}</Nm>
</InitgPty>
</GrpHdr>
<PmtInf>
<PmtInfId>${paymentid}</PmtInfId>
<PmtMtd>TRF</PmtMtd>
<NbOfTxs>${number_of_tx}</NbOfTxs>
<CtrlSum>${total_amount}</CtrlSum>
<PmtTpInf>
<SvcLvl>
<Cd>SEPA</Cd>
</SvcLvl>
</PmtTpInf>
<ReqdExctnDt>${payment_date}</ReqdExctnDt>
<Dbtr>
<Nm>${org_name}</Nm>
<Id>
<OrgId>
<BICOrBEI>${org_bic}</BICOrBEI>
</OrgId>
</Id>
</Dbtr>
<DbtrAcct>
<Id>
<IBAN>${org_iban}</IBAN>
</Id>
<Ccy>EUR</Ccy>
</DbtrAcct>
<DbtrAgt>
<FinInstnId>
<BIC>${org_bic}</BIC>
</FinInstnId>
</DbtrAgt>
`;
let payemnts = "";
for (const r of payments) {
const { vs, ks, ss, amount, name, iban, bic, comment } = r;
payemnts += ` <CdtTrfTxInf>
<PmtId>
<EndToEndId>/VS${vs}/SS${ss}/KS${ks}</EndToEndId>
</PmtId>
<Amt>
<InstdAmt Ccy="EUR">${amount}</InstdAmt>
</Amt>
<CdtrAgt>
<FinInstnId>
<BIC>${bic}</BIC>
</FinInstnId>
</CdtrAgt>
<Cdtr>
<Nm>${xmlEscape(name)}</Nm>
<Id>
<OrgId>
<BICOrBEI>${bic}</BICOrBEI>
</OrgId>
</Id>
</Cdtr>
<CdtrAcct>
<Id>
<IBAN>${iban}</IBAN>
</Id>
</CdtrAcct>
<RmtInf>
<Ustrd>${xmlEscape(comment)}</Ustrd>
</RmtInf>
</CdtTrfTxInf>
`
}
const suffix =
` </PmtInf>
</CstmrCdtTrfInitn>
</Document>`;
const xml = prefix + payemnts + suffix;
return xml;
}
const xmlEscape = (s:string) => {
if(!s)
return "";
return s.replaceAll("&", "&").replaceAll("<","<");
}
And that’s it.
Manually copying data from one app to another is not only a waste of time, it is also a big source of errors. When handling money, errors are a lot more painful. From embarrassment to possibly expensive fines when dealing with the tax and state authorities, it is best to be avoided.
We hope the code above will help you automate payments, avoid costly errors and streamline the whole process.
Happy hacking!