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:
@@ -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 & 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', () => {
|
||||
|
||||
@@ -17,6 +17,7 @@ import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { getBranding } from '@/lib/branding/service'
|
||||
import { getSwedishLocalDate } from '@/lib/bookkeeping/engine'
|
||||
import { ORE_TOLERANCE, roundOre, sumOre } from '@/lib/money'
|
||||
import { validateBankgiroNumber } from '@/lib/bankgiro/luhn'
|
||||
import {
|
||||
lookupBicByClearing,
|
||||
lookupBicByBankName,
|
||||
@@ -46,17 +47,21 @@ export interface BatchDebtor {
|
||||
org_number: string
|
||||
iban: string
|
||||
bic: string
|
||||
/** Company bankgiro digits; enables the BGNR-to-BGNR debit Swedbank wants. */
|
||||
bankgiro: string | null
|
||||
}
|
||||
|
||||
export type DebtorResolution =
|
||||
| { ok: true; debtor: BatchDebtor }
|
||||
| { ok: false; missing: 'iban' | 'bic' }
|
||||
| { ok: false; missing: 'iban' | 'bic' | 'org_number' }
|
||||
|
||||
/**
|
||||
* Resolve the paying company (pain.001 debtor) from settings, mirroring the
|
||||
* salary pain001 route: saved BIC first, then derivation from the clearing
|
||||
* number or bank name the company already entered, so most users only ever
|
||||
* fill in the IBAN.
|
||||
* fill in the IBAN. The org number is required: InitgPty must carry an OrgId
|
||||
* (Swedbank Validex PFH_002). The bankgiro rides along when valid so
|
||||
* bankgiro payees can be debited BGNR-to-BGNR.
|
||||
*/
|
||||
export async function resolveBatchDebtor(
|
||||
supabase: SupabaseClient,
|
||||
@@ -66,7 +71,7 @@ export async function resolveBatchDebtor(
|
||||
supabase.from('companies').select('name, org_number').eq('id', companyId).single(),
|
||||
supabase
|
||||
.from('company_settings')
|
||||
.select('company_name, iban, bic, clearing_number, bank_name')
|
||||
.select('company_name, org_number, iban, bic, bankgiro, clearing_number, bank_name')
|
||||
.eq('company_id', companyId)
|
||||
.single(),
|
||||
])
|
||||
@@ -80,13 +85,22 @@ export async function resolveBatchDebtor(
|
||||
lookupBicByBankName(settings?.bank_name)
|
||||
if (!bic) return { ok: false, missing: 'bic' }
|
||||
|
||||
// Settings first: it is the maintained value; companies.org_number is the
|
||||
// write-once onboarding snapshot and may be empty.
|
||||
const orgNumber = settings?.org_number?.trim() || company?.org_number?.trim() || ''
|
||||
if (!orgNumber.replace(/\D/g, '')) return { ok: false, missing: 'org_number' }
|
||||
|
||||
const bankgiroRaw = settings?.bankgiro ?? ''
|
||||
const bankgiro = validateBankgiroNumber(bankgiroRaw) ? bankgiroRaw.replace(/\D/g, '') : null
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
debtor: {
|
||||
name: settings?.company_name || company?.name || '',
|
||||
org_number: company?.org_number || '',
|
||||
org_number: orgNumber,
|
||||
iban,
|
||||
bic,
|
||||
bankgiro,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -135,7 +149,7 @@ export interface BatchPreview {
|
||||
excluded: Array<{ id: string; reason: BatchExclusionReason | 'not_found' }>
|
||||
total: number
|
||||
debtor_ok: boolean
|
||||
debtor_missing?: 'iban' | 'bic'
|
||||
debtor_missing?: 'iban' | 'bic' | 'org_number'
|
||||
}
|
||||
|
||||
export async function previewSupplierPaymentBatch(
|
||||
@@ -216,7 +230,7 @@ export interface CreateBatchInput {
|
||||
|
||||
export type CreateBatchResult =
|
||||
| { ok: true; batch: SupplierPaymentBatch }
|
||||
| { ok: false; code: 'debtor_incomplete'; missing: 'iban' | 'bic' }
|
||||
| { ok: false; code: 'debtor_incomplete'; missing: 'iban' | 'bic' | 'org_number' }
|
||||
| { ok: false; code: 'ineligible'; details: Array<{ id: string; reason: string }> }
|
||||
| { ok: false; code: 'amount_exceeds_remaining'; details: Array<{ id: string }> }
|
||||
| { ok: false; code: 'invalid_amount'; details: Array<{ id: string }> }
|
||||
@@ -394,7 +408,13 @@ export function renderSupplierPaymentBatchFile(
|
||||
|
||||
const debtor = batch.debtor_snapshot
|
||||
const content = generateSupplierPain001(
|
||||
{ name: debtor.name, orgNumber: debtor.org_number, iban: debtor.iban, bic: debtor.bic },
|
||||
{
|
||||
name: debtor.name,
|
||||
orgNumber: debtor.org_number,
|
||||
iban: debtor.iban,
|
||||
bic: debtor.bic,
|
||||
bankgiro: debtor.bankgiro ?? null,
|
||||
},
|
||||
payments,
|
||||
{ messageId: batch.msg_id, createdAt: batch.created_at },
|
||||
)
|
||||
|
||||
@@ -4,13 +4,14 @@
|
||||
*
|
||||
* 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: bankgiro, plusgiro and
|
||||
* account payees), cross-checked against Nordea Corporate Access Payables
|
||||
* pain.001 examples v2.6 (2026-06-22). Target banks: Swedbank, SEB,
|
||||
* Handelsbanken, Nordea (pain.001.001.03 uploaded in the corporate portal).
|
||||
* "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):
|
||||
* 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:
|
||||
@@ -22,15 +23,31 @@
|
||||
* 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.
|
||||
* - A Luhn-valid OCR reference rides RmtInf/Strd/CdtrRefInf with type code
|
||||
* SCOR, exactly one per transaction. Anything else is an unstructured
|
||||
* - 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. Without a
|
||||
* company bankgiro everything debits the IBAN, which Validex rule 020
|
||||
* accepts as long as the creditor carries a postal country, so Cdtr
|
||||
* always carries PstlAdr/Ctry SE (v1 is domestic-only by scope).
|
||||
* - 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.
|
||||
* 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
|
||||
* and bank-side duplicate detection (keyed on MsgId) stays meaningful.
|
||||
* 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.
|
||||
@@ -45,6 +62,8 @@ export interface SupplierPain001Debtor {
|
||||
orgNumber: string
|
||||
iban: string
|
||||
bic: string
|
||||
/** Company bankgiro (digits); enables the BGNR-to-BGNR debit Swedbank wants. */
|
||||
bankgiro?: string | null
|
||||
}
|
||||
|
||||
export interface SupplierPain001Payment {
|
||||
@@ -78,20 +97,31 @@ export function generateSupplierPain001(
|
||||
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, dates ascending; original order
|
||||
// preserved within a date so the file reads like the batch it came from.
|
||||
const byDate = new Map<string, SupplierPain001Payment[]>()
|
||||
// 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 group = byDate.get(payment.paymentDate)
|
||||
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 byDate.set(payment.paymentDate, [payment])
|
||||
else byGroup.set(key, [payment])
|
||||
}
|
||||
const dates = [...byDate.keys()].sort()
|
||||
const groupKeys = [...byGroup.keys()].sort()
|
||||
|
||||
const lines: string[] = []
|
||||
lines.push('<?xml version="1.0" encoding="UTF-8"?>')
|
||||
@@ -105,22 +135,22 @@ export function generateSupplierPain001(
|
||||
lines.push(` <NbOfTxs>${payments.length}</NbOfTxs>`)
|
||||
lines.push(` <CtrlSum>${formatDecimal(totalAmount)}</CtrlSum>`)
|
||||
lines.push(' <InitgPty>')
|
||||
lines.push(` <Nm>${escapeXml(debtor.name)}</Nm>`)
|
||||
if (orgDigits) {
|
||||
lines.push(' <Id>')
|
||||
lines.push(' <OrgId>')
|
||||
lines.push(` <Othr><Id>${escapeXml(orgDigits)}</Id></Othr>`)
|
||||
lines.push(' </OrgId>')
|
||||
lines.push(' </Id>')
|
||||
}
|
||||
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 < dates.length; g++) {
|
||||
const date = dates[g]
|
||||
const group = byDate.get(date) as SupplierPain001Payment[]
|
||||
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>`)
|
||||
@@ -130,18 +160,23 @@ export function generateSupplierPain001(
|
||||
lines.push(` <CtrlSum>${formatDecimal(groupTotal)}</CtrlSum>`)
|
||||
lines.push(` <ReqdExctnDt>${date}</ReqdExctnDt>`)
|
||||
lines.push(' <Dbtr>')
|
||||
lines.push(` <Nm>${escapeXml(debtor.name)}</Nm>`)
|
||||
if (orgDigits) {
|
||||
lines.push(' <Id>')
|
||||
lines.push(' <OrgId>')
|
||||
lines.push(` <Othr><Id>${escapeXml(orgDigits)}</Id></Othr>`)
|
||||
lines.push(' </OrgId>')
|
||||
lines.push(' </Id>')
|
||||
}
|
||||
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(' </Dbtr>')
|
||||
lines.push(' <DbtrAcct>')
|
||||
lines.push(' <Id>')
|
||||
lines.push(` <IBAN>${escapeXml(debtor.iban)}</IBAN>`)
|
||||
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>')
|
||||
@@ -164,7 +199,7 @@ export function generateSupplierPain001(
|
||||
lines.push(` <InstdAmt Ccy="SEK">${formatDecimal(payment.amount)}</InstdAmt>`)
|
||||
lines.push(' </Amt>')
|
||||
pushCreditor(lines, payment)
|
||||
pushRemittance(lines, payment.reference)
|
||||
pushRemittance(lines, payment.reference, payment.amount)
|
||||
lines.push(' </CdtTrfTxInf>')
|
||||
}
|
||||
|
||||
@@ -213,7 +248,13 @@ function pushCreditor(lines: string[], payment: SupplierPain001Payment): void {
|
||||
lines.push(' </FinInstnId>')
|
||||
lines.push(' </CdtrAgt>')
|
||||
lines.push(' <Cdtr>')
|
||||
lines.push(` <Nm>${escapeXml(payment.payeeName)}</Nm>`)
|
||||
lines.push(` <Nm>${escapeXml(sanitizeText(payment.payeeName))}</Nm>`)
|
||||
// Postal country always: v1 payees are Swedish-domestic by scope, and the
|
||||
// Swedbank MIG (Validex PFH_020/PFH_237) wants a creditor postal address
|
||||
// whenever the debit is not BGNR-to-BGNR. Harmless where not required.
|
||||
lines.push(' <PstlAdr>')
|
||||
lines.push(' <Ctry>SE</Ctry>')
|
||||
lines.push(' </PstlAdr>')
|
||||
lines.push(' </Cdtr>')
|
||||
lines.push(' <CdtrAcct>')
|
||||
lines.push(' <Id>')
|
||||
@@ -225,10 +266,15 @@ function pushCreditor(lines: string[], payment: SupplierPain001Payment): void {
|
||||
lines.push(' </CdtrAcct>')
|
||||
}
|
||||
|
||||
function pushRemittance(lines: string[], reference: PaymentReference): void {
|
||||
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>')
|
||||
@@ -237,7 +283,7 @@ function pushRemittance(lines: string[], reference: PaymentReference): void {
|
||||
lines.push(' </CdtrRefInf>')
|
||||
lines.push(' </Strd>')
|
||||
} else {
|
||||
lines.push(` <Ustrd>${escapeXml(reference.value.slice(0, USTRD_MAX))}</Ustrd>`)
|
||||
lines.push(` <Ustrd>${escapeXml(sanitizeText(reference.value).slice(0, USTRD_MAX))}</Ustrd>`)
|
||||
}
|
||||
lines.push(' </RmtInf>')
|
||||
}
|
||||
@@ -252,6 +298,36 @@ 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 escapeXml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
|
||||
Reference in New Issue
Block a user