Files
accounted/lib/payments/pain001-supplier.ts
T
f266c386f3 chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers (#2150)
* chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers

Remove 33 dead files, ~270 unreferenced exports/types, 13 dead i18n
namespaces and 4 unused dependencies; fold byte-identical helper copies
into one canonical home each (lib/utils chunk/sleep/utcDateStamp,
lib/dates/iso, lib/invariants/uuid, lib/xml/escape, lib/reports/sru/format,
lib/pdf/number-text, lib/browser/panel-request, lib/api/v1/body +
v1ValidationError rolled out to ~55 v1 routes, booking-template schemas).

No behaviour change: v1 bodies and status codes, MCP tool schemas, DB
writes and money math are untouched. Naive ore rounding was deliberately
not swapped for roundOre; see DECISIONS.md 2026-09-02 for the full list
of things left alone on purpose.

tsc, lint, 19588 unit tests and check:guards green; antipattern baseline
ratcheted (naive-ore-round 622 -> 620, hand-rolled-invariant 115 -> 113).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(transactions): import RawTransaction from @/types after the ingest re-export removal

CI's type ratchet (check:types, full tsconfig) caught the one test file
that still imported the type through lib/transactions/ingest.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 11:51:16 +02:00

360 lines
16 KiB
TypeScript

/**
* 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<string, SupplierPain001Payment[]>()
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('<?xml version="1.0" encoding="UTF-8"?>')
lines.push('<Document xmlns="urn:iso:std:iso:20022:tech:xsd:pain.001.001.03"')
lines.push(' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">')
lines.push(' <CstmrCdtTrfInitn>')
lines.push(' <GrpHdr>')
lines.push(` <MsgId>${escapeXml(msgId)}</MsgId>`)
lines.push(` <CreDtTm>${creDtTm}</CreDtTm>`)
lines.push(` <NbOfTxs>${payments.length}</NbOfTxs>`)
lines.push(` <CtrlSum>${formatDecimal(totalAmount)}</CtrlSum>`)
lines.push(' <InitgPty>')
lines.push(` <Nm>${escapeXml(debtorName)}</Nm>`)
lines.push(' <Id>')
lines.push(' <OrgId>')
lines.push(` <Othr><Id>${escapeXml(orgDigits)}</Id></Othr>`)
lines.push(' </OrgId>')
lines.push(' </Id>')
lines.push(' </InitgPty>')
lines.push(' </GrpHdr>')
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(' <PmtInf>')
lines.push(` <PmtInfId>${escapeXml(suffixId(msgId, `-P${g + 1}`))}</PmtInfId>`)
lines.push(' <PmtMtd>TRF</PmtMtd>')
lines.push(' <BtchBookg>true</BtchBookg>')
lines.push(` <NbOfTxs>${group.length}</NbOfTxs>`)
lines.push(` <CtrlSum>${formatDecimal(groupTotal)}</CtrlSum>`)
lines.push(` <ReqdExctnDt>${date}</ReqdExctnDt>`)
lines.push(' <Dbtr>')
lines.push(` <Nm>${escapeXml(debtorName)}</Nm>`)
// XSD order in PostalAddress6: TwnNm before Ctry; PstlAdr before Id.
if (debtor.city?.trim()) {
lines.push(' <PstlAdr>')
lines.push(` <TwnNm>${escapeXml(sanitizeText(debtor.city.trim()))}</TwnNm>`)
lines.push(' <Ctry>SE</Ctry>')
lines.push(' </PstlAdr>')
}
lines.push(' <Id>')
lines.push(' <OrgId>')
lines.push(` <Othr><Id>${escapeXml(orgDigits)}</Id></Othr>`)
lines.push(' </OrgId>')
lines.push(' </Id>')
lines.push(' </Dbtr>')
lines.push(' <DbtrAcct>')
lines.push(' <Id>')
if (bgnrDebit) {
lines.push(' <Othr>')
lines.push(` <Id>${escapeXml(debtorBankgiro)}</Id>`)
lines.push(' <SchmeNm><Prtry>BGNR</Prtry></SchmeNm>')
lines.push(' </Othr>')
} else {
lines.push(` <IBAN>${escapeXml(debtor.iban)}</IBAN>`)
}
lines.push(' </Id>')
lines.push(' <Ccy>SEK</Ccy>')
lines.push(' </DbtrAcct>')
lines.push(' <DbtrAgt>')
lines.push(' <FinInstnId>')
lines.push(` <BIC>${escapeXml(debtor.bic)}</BIC>`)
lines.push(' </FinInstnId>')
lines.push(' </DbtrAgt>')
for (const payment of group) {
txCounter += 1
const txId = suffixId(msgId, `-TX${String(txCounter).padStart(4, '0')}`)
lines.push(' <CdtTrfTxInf>')
lines.push(' <PmtId>')
lines.push(` <InstrId>${escapeXml(txId)}</InstrId>`)
lines.push(` <EndToEndId>${escapeXml(txId)}</EndToEndId>`)
lines.push(' </PmtId>')
lines.push(' <Amt>')
lines.push(` <InstdAmt Ccy="SEK">${formatDecimal(payment.amount)}</InstdAmt>`)
lines.push(' </Amt>')
pushCreditor(lines, payment)
pushRemittance(lines, payment.reference, payment.amount)
lines.push(' </CdtTrfTxInf>')
}
lines.push(' </PmtInf>')
}
lines.push(' </CstmrCdtTrfInitn>')
lines.push('</Document>')
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 = '<SchmeNm><Prtry>BGNR</Prtry></SchmeNm>'
break
case 'plusgiro':
memberId = '9960'
accountId = payee.plusgiro
scheme = '<SchmeNm><Cd>BBAN</Cd></SchmeNm>'
break
case 'bank_account': {
const { clearing4, accountDigits } = splitDomesticBankAccount(payee.clearing, payee.account)
memberId = clearing4
accountId = accountDigits
scheme = '<SchmeNm><Cd>BBAN</Cd></SchmeNm>'
break
}
}
lines.push(' <CdtrAgt>')
lines.push(' <FinInstnId>')
lines.push(' <ClrSysMmbId>')
lines.push(' <ClrSysId><Cd>SESBA</Cd></ClrSysId>')
lines.push(` <MmbId>${memberId}</MmbId>`)
lines.push(' </ClrSysMmbId>')
lines.push(' </FinInstnId>')
lines.push(' </CdtrAgt>')
lines.push(' <Cdtr>')
lines.push(` <Nm>${escapeXml(sanitizeText(payment.payeeName))}</Nm>`)
// 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(' <PstlAdr>')
if (payment.payeeCity?.trim()) {
lines.push(` <TwnNm>${escapeXml(sanitizeText(payment.payeeCity.trim()))}</TwnNm>`)
}
lines.push(' <Ctry>SE</Ctry>')
lines.push(' </PstlAdr>')
lines.push(' </Cdtr>')
lines.push(' <CdtrAcct>')
lines.push(' <Id>')
lines.push(' <Othr>')
lines.push(` <Id>${escapeXml(accountId)}</Id>`)
lines.push(` ${scheme}`)
lines.push(' </Othr>')
lines.push(' </Id>')
lines.push(' </CdtrAcct>')
}
function pushRemittance(lines: string[], reference: PaymentReference, amount: number): void {
lines.push(' <RmtInf>')
if (reference.type === 'ocr') {
lines.push(' <Strd>')
// 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(' <RfrdDocAmt>')
lines.push(` <RmtdAmt Ccy="SEK">${formatDecimal(amount)}</RmtdAmt>`)
lines.push(' </RfrdDocAmt>')
lines.push(' <CdtrRefInf>')
lines.push(' <Tp>')
lines.push(' <CdOrPrtry><Cd>SCOR</Cd></CdOrPrtry>')
lines.push(' </Tp>')
lines.push(` <Ref>${escapeXml(reference.value)}</Ref>`)
lines.push(' </CdtrRefInf>')
lines.push(' </Strd>')
} else {
lines.push(` <Ustrd>${escapeXml(sanitizeText(reference.value).slice(0, USTRD_MAX))}</Ustrd>`)
}
lines.push(' </RmtInf>')
}
// ============================================================
// 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<string, string> = {
'é': '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
}