July 14, 2025

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.

Online Banking with SEPA Mass payment

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..

The job

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.

Preparation

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;
}

Generating the xml file

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

  • The sender: who is sending the money (lebowsky)
  • The summary - number of transactions and a total. Mostly for verification.
  • The list of transactions - with recipient and money amount.

Each section needs an id, the current date is enough.

What info do we need for the header

  • Name of the payer
  • IBAN and SWIFT/BIC of the payer
  • Number of transactions
  • Total sum of amount in transactions
  • When to issue the payments

Then for each transaction

  • Name, IBAN and SWIFT/BIC of the recipient
  • Amount
  • Currecny - we assume euro. We haven't tested other currencies, possibly a domestic ISO_4217 currency code would be accepted
  • PaymentId - in slovakia these are the variable, specific and constant symbols, serialized as /VS…/KS…/SS…
  • An message comment for the recipient.

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("&", "&amp;").replaceAll("<","&lt;");
}

And that’s it.

Wrap

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!

Continue reading

Making Addresses Work

SQL OR condition on joins

Make an appointment and book a meeting room

April 13, 2025
Make an appointment and book a meeting room

Do I live in the moment or is my past self controlling my life? I do things, he put in the calendar, call people he arranged meetings with. It makes me wonder, for a short while. Then I remember I need to start planning work for my future self.

©2022-2025 Inuko s.r.o
Privacy PolicyCloud
ENSKCZ
ICO 54507006
VAT SK2121695400
Vajanskeho 5
Senec 90301
Slovakia EU

contact@inuko.net