fix(payments): correct pain.001 dialect per Swedbank Validex run (#1507)

* fix(payments): correct pain.001 dialect per Swedbank Validex run

Real MIG validation (eken.validex.net) rejected the first generated file
on four rules: character set (e-acute in names), missing InitgPty OrgId,
BGNR creditors demanding a BGNR debtor, and Strd lacking RfrdDocAmt.
Names and messages now transliterate to the MIG set, the org number is
required at batch creation (settings first, companies fallback), bankgiro
payees debit the company bankgiro in their own PmtInf group when one
exists (IBAN otherwise, with Cdtr PstlAdr/Ctry SE always present), and
structured OCR remittance repeats the amount as RfrdDocAmt.

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

* fix(payments): review quick wins on the MIG pass

NFC-normalize before transliteration (decomposed marks from PDF-pasted
names fold to the precomposed forms the map knows), a dedicated settings
link label for the missing-org state, and coverage for an invalid
company bankgiro being dropped from the debtor snapshot.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-10 20:53:43 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent f0bedc14af
commit fea5dfd1f9
9 changed files with 281 additions and 54 deletions
@@ -15,8 +15,10 @@ const USER_ID = 'u0000000-0000-0000-0000-000000000001'
const companyRow = { name: 'Testbolaget AB', org_number: '556677-8899' }
const settingsRow = {
company_name: 'Testbolaget AB',
org_number: '556677-8899',
iban: 'SE3550000000054910000003',
bic: 'ESSESESS',
bankgiro: '991-2346',
clearing_number: null,
bank_name: null,
}
@@ -213,6 +215,7 @@ describe('createSupplierPaymentBatch', () => {
org_number: '556677-8899',
iban: 'SE3550000000054910000003',
bic: 'ESSESESS',
bankgiro: '9912346',
},
})
const msgId = batchInsert.msg_id as string
@@ -372,6 +375,48 @@ describe('createSupplierPaymentBatch', () => {
expect(result).toEqual({ ok: false, code: 'debtor_incomplete', missing: 'iban' })
})
it('drops an invalid company bankgiro from the snapshot instead of debiting it', async () => {
const mock = createQueuedMockSupabase()
mock.enqueueMany([
{ data: companyRow },
{ data: { ...settingsRow, bankgiro: '1234-5678' } },
{ data: [invoiceRow()] },
{ data: [] },
{ data: batchRow() },
{ data: null },
])
const result = await createSupplierPaymentBatch(
mock.supabase as unknown as SupabaseClient,
COMPANY_ID,
USER_ID,
{ format: 'pain001', items: [{ supplier_invoice_id: 'inv-1' }] },
)
expect(result.ok).toBe(true)
const batchInsert = mock.findCall('supplier_payment_batches', 'insert')?.[0] as {
debtor_snapshot: { bankgiro: string | null }
}
expect(batchInsert.debtor_snapshot.bankgiro).toBeNull()
})
it('requires an organisation number for the InitgPty OrgId', async () => {
const mock = createQueuedMockSupabase()
mock.enqueueMany([
{ data: { ...companyRow, org_number: null } },
{ data: { ...settingsRow, org_number: null } },
])
const result = await createSupplierPaymentBatch(
mock.supabase as unknown as SupabaseClient,
COMPANY_ID,
USER_ID,
{ format: 'pain001', items: [{ supplier_invoice_id: 'inv-1' }] },
)
expect(result).toEqual({ ok: false, code: 'debtor_incomplete', missing: 'org_number' })
})
})
describe('renderSupplierPaymentBatchFile', () => {
@@ -80,14 +80,81 @@ describe('generateSupplierPain001', () => {
expect(xml).toContain('<SchmeNm><Cd>BBAN</Cd></SchmeNm>')
})
it('renders exactly one structured SCOR reference for an OCR payment', () => {
it('renders exactly one structured SCOR reference with the remitted amount', () => {
const xml = generateSupplierPain001(debtor, [bgPayment()], options)
expect(xml.match(/<CdtrRefInf>/g)).toHaveLength(1)
expect(xml).toContain('<CdOrPrtry><Cd>SCOR</Cd></CdOrPrtry>')
expect(xml).toContain('<Ref>12345678</Ref>')
// Swedbank MIG (Validex PFH_217): Strd must carry RfrdDocAmt.
expect(xml).toContain('<RmtdAmt Ccy="SEK">737.50</RmtdAmt>')
expect(xml.indexOf('<RfrdDocAmt>')).toBeLessThan(xml.indexOf('<CdtrRefInf>'))
expect(xml).not.toContain('<Ustrd>')
})
it('always carries the creditor postal country and the initiator OrgId', () => {
const xml = generateSupplierPain001(debtor, [bgPayment()], options)
// Swedbank MIG rules 020/237 and 002 (Validex run 2026-08-10).
expect(xml).toContain('<PstlAdr>')
expect(xml).toContain('<Ctry>SE</Ctry>')
expect(xml.match(/<OrgId>/g)!.length).toBeGreaterThanOrEqual(2)
expect(xml).toContain('<Othr><Id>5566778899</Id></Othr>')
})
it('refuses a debtor without an organisation number', () => {
expect(() =>
generateSupplierPain001({ ...debtor, orgNumber: '' }, [bgPayment()], options),
).toThrow(/Organisationsnummer/)
})
it('debits the company bankgiro for bankgiro payees and the IBAN for others', () => {
const xml = generateSupplierPain001(
{ ...debtor, bankgiro: '9912346' },
[
bgPayment(),
bgPayment({ payee: { type: 'plusgiro', plusgiro: '1234567' }, amount: 100 }),
],
options,
)
// Same date, two debit forms -> two PmtInf groups (Swedbank rule 219:
// BGNR creditors debit the bankgiro; the plusgiro payment debits the IBAN).
expect(xml.match(/<PmtInf>/g)).toHaveLength(2)
const debtorAccounts = xml.match(/<DbtrAcct>[\s\S]*?<\/DbtrAcct>/g) ?? []
expect(debtorAccounts).toHaveLength(2)
expect(debtorAccounts.filter((block) => block.includes('BGNR'))).toHaveLength(1)
expect(debtorAccounts.find((block) => block.includes('BGNR'))).toContain(
'<Id>9912346</Id>',
)
expect(debtorAccounts.filter((block) => block.includes(debtor.iban))).toHaveLength(1)
})
it('keeps everything on the IBAN when the company has no bankgiro', () => {
const xml = generateSupplierPain001(debtor, [bgPayment()], options)
const debtorAccounts = xml.match(/<DbtrAcct>[\s\S]*?<\/DbtrAcct>/g) ?? []
expect(debtorAccounts).toHaveLength(1)
expect(debtorAccounts[0]).toContain(`<IBAN>${debtor.iban}</IBAN>`)
expect(debtorAccounts[0]).not.toContain('BGNR')
})
it('transliterates disallowed characters in names (MIG character set)', () => {
const xml = generateSupplierPain001(
debtor,
[bgPayment({ payeeName: 'Demokafé & Crème AB' })],
options,
)
expect(xml).toContain('<Nm>Demokafe + Creme AB</Nm>')
})
it('folds decomposed Unicode before transliterating', () => {
// 'e' + combining acute (U+0301) and 'a' + combining ring (U+030A):
// NFC folds them to é and å, which then map per the MIG set.
const xml = generateSupplierPain001(
debtor,
[bgPayment({ payeeName: 'Café Ångby' })],
options,
)
expect(xml).toContain('<Nm>Cafe Ångby</Nm>')
})
it('renders an invoice-number reference as unstructured text, truncated to 25 chars', () => {
const xml = generateSupplierPain001(
debtor,
@@ -129,9 +196,13 @@ describe('generateSupplierPain001', () => {
expect(xml.match(/<PmtInfId>[^<]*-P2<\/PmtInfId>/)).not.toBeNull()
})
it('escapes XML special characters in names', () => {
const xml = generateSupplierPain001(debtor, [bgPayment()], options)
expect(xml).toContain('<Nm>Derome Bygg &amp; Industri AB</Nm>')
it('maps ampersands to + per the MIG character set (Swedish å ä ö survive)', () => {
const xml = generateSupplierPain001(
debtor,
[bgPayment({ payeeName: 'Derome Bygg & Industri Åängö AB' })],
options,
)
expect(xml).toContain('<Nm>Derome Bygg + Industri Åängö AB</Nm>')
})
it('keeps all ids within Max35Text with the suffix intact', () => {