/** * pain.001 (ISO 20022) payment file generator for supplier payments * (leverantorsbetalningar). * * Dialect: Swedish DOMESTIC giro credit transfers per the Swedish Common * Interpretation of ISO 20022 payment messages (Svenska Bankforeningen, * "Common Payment Types in Sweden", Appendix 1), cross-checked against * Nordea Corporate Access Payables pain.001 examples v2.6 (2026-06-22) and * validated against Swedbank Validex (eken.validex.net, Swedbank MIG 1.0, * run 2026-08-10). Target banks: Swedbank, SEB, Handelsbanken, Nordea * (pain.001.001.03 uploaded in the corporate portal). * * Wire-format constraints this file encodes (do not "improve" without a * bank implementation guide in hand): * * - No SvcLvl element: SvcLvl SEPA means a SEPA credit transfer (EUR-only); * the domestic default (NURG) applies when SvcLvl is omitted. No CtgyPurp: * SALA/PENS mark salary rails; supplier giro payments are plain transfers. * - Bankgiro payees are addressed through Bankgirot: CdtrAgt ClrSysMmbId * SESBA member 9900, CdtrAcct/Id/Othr with the bare BG digits and * SchmeNm/Prtry BGNR. Plusgiro payees route SESBA member 9960 with * SchmeNm/Cd BBAN. Bank-account payees use the clearing number as the * SESBA member and the account (without clearing) as BBAN, through the * same splitDomesticBankAccount used by the salary generator so the two * files can never route an account differently. * - Swedbank MIG (Validex PFH_pain_001_001_03_219): a BGNR creditor demands * a BGNR debtor. When the company has a bankgiro, bankgiro-payee payments * are grouped into their own PmtInf debited from the company bankgiro * (DbtrAcct Othr/BGNR); other payees are debited from the IBAN. * - Creditor postal address ALWAYS (Validex round 3, rule PFH_237 fires on * the BGNR path too, overriding what rule 020's phrasing suggested), and * a present PstlAdr must carry TwnNm (rule PFH_222, enforced now): the * supplier's town when known, plus Ctry SE (v1 is domestic-only). A payee * without a town yields a Ctry-only address that Swedbank rejects, so the * preview warns when the supplier register lacks a city. The debtor * address rides along the same way from company settings. * - InitgPty and Dbtr always carry OrgId (Validex PFH_002: InitgPty * other/Id must be stated); the batch service refuses to create a batch * for a company without an organisationsnummer. * - A Luhn-valid OCR reference rides RmtInf/Strd with the amount repeated * as RfrdDocAmt/RmtdAmt (Validex PFH_217) and CdtrRefInf type SCOR, * exactly one per transaction. Anything else is an unstructured * RmtInf/Ustrd message (the giro "meddelande" field). * - Free text is restricted to the MIG character set (Validex PFH_214): * Swedish letters survive, other accented letters transliterate (e for * e-acute, u for u-umlaut), anything else becomes '?'. Identifiers and * references are ASCII digits by construction. * - MsgId, PmtInfId, InstrId and EndToEndId are Max35Text. * - ReqdExctnDt sits on PmtInf, so payments are grouped into one PmtInf per * distinct (payment date, debtor account form). * - Determinism: CreDtTm comes from the caller (the batch row's created_at), * never from the clock, so re-generating a stored batch is byte-identical * for the same generator version and bank-side duplicate detection (keyed * on MsgId) stays meaningful. * * Per BFL: the generated file is rakenskapsinformation (underlag) for the * payments it initiates. Subject to 7-year retention. */ import { escapeXml } from '@/lib/xml/escape' import { roundOre } from '@/lib/money' import { splitDomesticBankAccount } from '@/lib/salary/payment/bank-account' import type { PaymentReference, SupplierPayee } from './supplier-payee' export interface SupplierPain001Debtor { name: string orgNumber: string iban: string bic: string /** Company bankgiro (digits); enables the BGNR-to-BGNR debit Swedbank wants. */ bankgiro?: string | null /** Company town; emitted as Dbtr/PstlAdr/TwnNm when present. */ city?: string | null } export interface SupplierPain001Payment { payee: SupplierPayee payeeName: string /** Supplier town; Cdtr/PstlAdr/TwnNm on IBAN-debited payments when known. */ payeeCity?: string | null amount: number /** YYYY-MM-DD requested execution date. */ paymentDate: string reference: PaymentReference } export interface SupplierPain001Options { /** Stored batch msg_id; reused verbatim on regeneration. */ messageId: string /** Batch created_at (ISO timestamp): becomes CreDtTm, NOT the clock. */ createdAt: string } /** The receiver-side giro message field is 25 positions; keep Ustrd within it. */ const USTRD_MAX = 25 export function generateSupplierPain001( debtor: SupplierPain001Debtor, payments: SupplierPain001Payment[], options: SupplierPain001Options, ): string { if (payments.length === 0) { throw new Error('Betalfilen måste innehålla minst en betalning') } const creDtTm = new Date(options.createdAt).toISOString().replace(/\.\d{3}Z$/, 'Z') const msgId = max35(options.messageId) const orgDigits = debtor.orgNumber.replace(/\D/g, '') if (!orgDigits) { // Validex PFH_002: InitgPty other/Id must be stated. The batch service // guarantees this; a missing org number here is a programming error. throw new Error('Organisationsnummer saknas för betalfilens avsändare') } const debtorBankgiro = (debtor.bankgiro ?? '').replace(/\D/g, '') const debtorName = sanitizeText(debtor.name) // Sum the per-transaction amounts exactly as they are rendered (rounded to // ore): CtrlSum must equal the sum of the InstdAmt values or banks reject // the file, and summing raw floats then rounding once can differ by an ore. const totalAmount = sumRendered(payments) // One PmtInf per distinct (execution date, debtor account form): a BGNR // creditor must debit the company bankgiro (Swedbank rule 219), everything // else debits the IBAN, and ReqdExctnDt is PmtInf-level. Original order is // preserved within a group so the file reads like the batch it came from. const byGroup = new Map() for (const payment of payments) { const bgnrDebit = debtorBankgiro !== '' && payment.payee.type === 'bankgiro' const key = `${payment.paymentDate}|${bgnrDebit ? 'bgnr' : 'acct'}` const group = byGroup.get(key) if (group) group.push(payment) else byGroup.set(key, [payment]) } const groupKeys = [...byGroup.keys()].sort() const lines: string[] = [] lines.push('') lines.push('') lines.push(' ') lines.push(' ') lines.push(` ${escapeXml(msgId)}`) lines.push(` ${creDtTm}`) lines.push(` ${payments.length}`) lines.push(` ${formatDecimal(totalAmount)}`) lines.push(' ') lines.push(` ${escapeXml(debtorName)}`) lines.push(' ') lines.push(' ') lines.push(` ${escapeXml(orgDigits)}`) lines.push(' ') lines.push(' ') lines.push(' ') lines.push(' ') let txCounter = 0 for (let g = 0; g < groupKeys.length; g++) { const key = groupKeys[g] const [date, form] = key.split('|') const group = byGroup.get(key) as SupplierPain001Payment[] const groupTotal = sumRendered(group) const bgnrDebit = form === 'bgnr' lines.push(' ') lines.push(` ${escapeXml(suffixId(msgId, `-P${g + 1}`))}`) lines.push(' TRF') lines.push(' true') lines.push(` ${group.length}`) lines.push(` ${formatDecimal(groupTotal)}`) lines.push(` ${date}`) lines.push(' ') lines.push(` ${escapeXml(debtorName)}`) // XSD order in PostalAddress6: TwnNm before Ctry; PstlAdr before Id. if (debtor.city?.trim()) { lines.push(' ') lines.push(` ${escapeXml(sanitizeText(debtor.city.trim()))}`) lines.push(' SE') lines.push(' ') } lines.push(' ') lines.push(' ') lines.push(` ${escapeXml(orgDigits)}`) lines.push(' ') lines.push(' ') lines.push(' ') lines.push(' ') lines.push(' ') if (bgnrDebit) { lines.push(' ') lines.push(` ${escapeXml(debtorBankgiro)}`) lines.push(' BGNR') lines.push(' ') } else { lines.push(` ${escapeXml(debtor.iban)}`) } lines.push(' ') lines.push(' SEK') lines.push(' ') lines.push(' ') lines.push(' ') lines.push(` ${escapeXml(debtor.bic)}`) lines.push(' ') lines.push(' ') for (const payment of group) { txCounter += 1 const txId = suffixId(msgId, `-TX${String(txCounter).padStart(4, '0')}`) lines.push(' ') lines.push(' ') lines.push(` ${escapeXml(txId)}`) lines.push(` ${escapeXml(txId)}`) lines.push(' ') lines.push(' ') lines.push(` ${formatDecimal(payment.amount)}`) lines.push(' ') pushCreditor(lines, payment) pushRemittance(lines, payment.reference, payment.amount) lines.push(' ') } lines.push(' ') } lines.push(' ') lines.push('') return lines.join('\n') } /** XSD order within CdtTrfTxInf: CdtrAgt before Cdtr before CdtrAcct. */ function pushCreditor(lines: string[], payment: SupplierPain001Payment): void { const { payee } = payment let memberId: string let accountId: string let scheme: string switch (payee.type) { case 'bankgiro': memberId = '9900' accountId = payee.bankgiro scheme = 'BGNR' break case 'plusgiro': memberId = '9960' accountId = payee.plusgiro scheme = 'BBAN' break case 'bank_account': { const { clearing4, accountDigits } = splitDomesticBankAccount(payee.clearing, payee.account) memberId = clearing4 accountId = accountDigits scheme = 'BBAN' break } } lines.push(' ') lines.push(' ') lines.push(' ') lines.push(' SESBA') lines.push(` ${memberId}`) lines.push(' ') lines.push(' ') lines.push(' ') lines.push(' ') lines.push(` ${escapeXml(sanitizeText(payment.payeeName))}`) // Always (Validex round 3, rule 237 fires on every path): the supplier's // town when known and country SE (v1 scope). XSD order: TwnNm before Ctry. lines.push(' ') if (payment.payeeCity?.trim()) { lines.push(` ${escapeXml(sanitizeText(payment.payeeCity.trim()))}`) } lines.push(' SE') lines.push(' ') lines.push(' ') lines.push(' ') lines.push(' ') lines.push(' ') lines.push(` ${escapeXml(accountId)}`) lines.push(` ${scheme}`) lines.push(' ') lines.push(' ') lines.push(' ') } function pushRemittance(lines: string[], reference: PaymentReference, amount: number): void { lines.push(' ') if (reference.type === 'ocr') { lines.push(' ') // Validex PFH_217: RfrdDocAmt must be stated when Strd is provided. The // remitted amount equals the instructed amount for a full-line payment. lines.push(' ') lines.push(` ${formatDecimal(amount)}`) lines.push(' ') lines.push(' ') lines.push(' ') lines.push(' SCOR') lines.push(' ') lines.push(` ${escapeXml(reference.value)}`) lines.push(' ') lines.push(' ') } else { lines.push(` ${escapeXml(sanitizeText(reference.value).slice(0, USTRD_MAX))}`) } lines.push(' ') } // ============================================================ // Helpers (deliberately duplicated from the salary generator: that dialect is // production-hardened and stays untouched; see DECISIONS.md 2026-08-10) // ============================================================ /** Control sums add the amounts AS RENDERED: each rounded to öre first. */ function sumRendered(payments: readonly SupplierPain001Payment[]): number { return payments.reduce((sum, p) => sum + roundOre(p.amount), 0) } /** * MIG character-set restriction (Validex PFH_pain_001_001_03_214): Swedish * letters pass through, other accented Latin letters transliterate to their * base letter, and anything still outside the allowed set becomes '?', the * same substitution the Bankgirot LB generator uses. Sanitize BEFORE * escapeXml so entity-encoded characters are never mangled. */ const TRANSLITERATIONS: Record = { 'é': 'e', 'è': 'e', 'ê': 'e', 'ë': 'e', 'É': 'E', 'È': 'E', 'Ê': 'E', 'Ë': 'E', 'á': 'a', 'à': 'a', 'â': 'a', 'ã': 'a', 'Á': 'A', 'À': 'A', 'Â': 'A', 'Ã': 'A', 'í': 'i', 'ì': 'i', 'î': 'i', 'ï': 'i', 'Í': 'I', 'Ì': 'I', 'Î': 'I', 'Ï': 'I', 'ó': 'o', 'ò': 'o', 'ô': 'o', 'õ': 'o', 'Ó': 'O', 'Ò': 'O', 'Ô': 'O', 'Õ': 'O', 'ú': 'u', 'ù': 'u', 'û': 'u', 'ü': 'u', 'Ú': 'U', 'Ù': 'U', 'Û': 'U', 'Ü': 'U', 'ý': 'y', 'ÿ': 'y', 'Ý': 'Y', 'ñ': 'n', 'Ñ': 'N', 'ç': 'c', 'Ç': 'C', 'ø': 'o', 'Ø': 'O', 'æ': 'a', 'Æ': 'A', 'ß': 'ss', 'š': 's', 'Š': 'S', 'ž': 'z', 'Ž': 'Z', 'đ': 'd', 'Đ': 'D', // '+' is in the allowed set and reads naturally where Swedish names use '&'. '&': '+', } const DISALLOWED_TEXT = /[^0-9A-Za-zåäöÅÄÖ/\-?:().,'+ ]/g function sanitizeText(value: string): string { // NFC first: decomposed input (base letter + combining mark, common in text // pasted from PDFs) must fold to the precomposed forms the map knows. let transliterated = '' for (const ch of value.normalize('NFC')) transliterated += TRANSLITERATIONS[ch] ?? ch return transliterated.replace(DISALLOWED_TEXT, '?') } function formatDecimal(amount: number): string { return roundOre(amount).toFixed(2) } function max35(value: string): string { return value.slice(0, 35) } function suffixId(base: string, suffix: string): string { return base.slice(0, Math.max(1, 35 - suffix.length)) + suffix }