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:
@@ -856,3 +856,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-08-10] committed_at overrides audited via a dedicated COMMITTED_AT_OVERRIDE action and a SECURITY DEFINER writer (#1444): reusing SECURITY_EVENT would overload attack semantics onto a sanctioned backdate, and a plain INSERT inside set_committed_at() fails under the pg harness where SET ROLE service_role has no BYPASSRLS and audit_log has no INSERT policy. EXECUTE revoked from anon/authenticated so PostgREST cannot expose the writer as an audit-noise RPC.
|
||||
[2026-08-10] Atom seed migrations gained a version-downgrade guard in the generator (#1483): each seed is a full 108-atom upsert built from its branch's tree, so two parallel PRs each carrying a seed meant the last-applied one silently reverted the other's atom bodies while skills:check stayed green (it compares files to the manifest, never the DB). The ON CONFLICT now skips rows whose registry version is newer than the seed's. Dance-event VAT stayed guidance-plus-descriptor only: no rate cutover logic in the engine, since 6% already exists as a treatment and the entry date decides the rate the user picks.
|
||||
[2026-08-10] Supplier betalfil (payment batches) ships pain.001.001.03 ONLY, no Bankgirot LB generator (founder call via scope questions): at ship time LB is dead at Handelsbanken/SEB and dies at Swedbank for supplier files 1 Sep 2026; Danske replaces LB with ISO 20022 during its Apr-Nov 2026 modernisation window without publishing a per-format date (27 Nov 2026 = all file communication must go via the bank; verified against danskebank.se 2026-08-10 after a bot review claimed 12 May, which is actually the bankgiro-alias initiation date). An LB path would have weeks of shelf life at the majors; the DB format CHECK still allows 'bg_lb' so a future LB or pain.001.001.09 addition needs no migration, the API Zod schema gates to 'pain001'. The supplier pain.001 generator (lib/payments/pain001-supplier.ts) reimplements the four tiny XML helpers instead of exporting them from lib/salary/payment/pain001-generator.ts: ~40 duplicated lines beat destabilizing a production-hardened salary dialect (2026-07-12 entry), and the two dialects genuinely differ (supplier files carry RmtInf SCOR/Ustrd and giro creditor addressing SESBA 9900 BGNR / 9960 BBAN; salary forbids RmtInf and marks CtgyPurp SALA). splitDomesticBankAccount IS shared so account routing can never diverge. Batches fix the documented no-regeneration-guard hazard (2026-07-26 entry) by construction: msg_id derives from the batch id at creation and CreDtTm from created_at, so re-download is byte-identical (bank dedup on MsgId works) and only a new batch mints a new MsgId. Generating/downloading a file books nothing: settlement stays in mark-paid/bank-match, matching the help_body direction that payment truth comes from the bank.
|
||||
[2026-08-10] Supplier pain.001 dialect corrected against a real Swedbank Validex run (MIG 1.0, eken.validex.net), which is stricter than the Bankforeningen appendix the generator was built from. Four generator changes: (1) MIG character set enforced by transliteration (a-acute to a, ampersand to plus, Swedish aao survive; leftovers become '?', matching LB padText), because rule 214 rejects e-acute in names outright; (2) InitgPty/Dbtr OrgId is now mandatory and the batch service refuses companies without organisationsnummer (rule 002), reading settings.org_number before the write-once companies.org_number; (3) BGNR creditors debit the company bankgiro when one exists, in their own PmtInf per (date, debit-form) group, because rule 219 demands BGNR-to-BGNR, with Cdtr PstlAdr/Ctry SE always present so IBAN-debited giro payments stay valid under rule 020 (v1 is domestic-only); (4) Strd carries RfrdDocAmt/RmtdAmt = the instructed amount (rule 217). Consequence accepted: the byte-identical re-download contract holds per generator version, not across dialect fixes; MsgId is unchanged so bank-side dedup still keys correctly. Validex account creation has a reCAPTCHA that was deliberately not circumvented; the founder registers and uploads.
|
||||
|
||||
@@ -38,7 +38,7 @@ interface Preview {
|
||||
excluded: Array<{ id: string; reason: string }>
|
||||
total: number
|
||||
debtor_ok: boolean
|
||||
debtor_missing?: 'iban' | 'bic'
|
||||
debtor_missing?: 'iban' | 'bic' | 'org_number'
|
||||
}
|
||||
|
||||
interface PaymentFileDialogProps {
|
||||
@@ -220,10 +220,18 @@ export default function PaymentFileDialog({
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{!preview.debtor_ok && (
|
||||
<AttnLine action={{ label: t('debtor_missing_link'), href: '/settings/invoicing' }}>
|
||||
<AttnLine
|
||||
action={
|
||||
preview.debtor_missing === 'org_number'
|
||||
? { label: t('debtor_missing_org_link'), href: '/settings/company' }
|
||||
: { label: t('debtor_missing_link'), href: '/settings/invoicing' }
|
||||
}
|
||||
>
|
||||
{preview.debtor_missing === 'bic'
|
||||
? t('debtor_missing_bic')
|
||||
: t('debtor_missing_iban')}
|
||||
: preview.debtor_missing === 'org_number'
|
||||
? t('debtor_missing_org')
|
||||
: t('debtor_missing_iban')}
|
||||
</AttnLine>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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, '&')
|
||||
|
||||
@@ -910,7 +910,9 @@
|
||||
"pain001_agreement_warning": "The file is in ISO 20022 format (pain.001) and is uploaded in your internet bank. Some banks require a file communication agreement; verify that your bank accepts the file well before the payment date.",
|
||||
"debtor_missing_iban": "The company IBAN is missing and is needed as the sender account in the payment file.",
|
||||
"debtor_missing_bic": "The company bank's BIC is missing and could not be derived.",
|
||||
"debtor_missing_org": "The company organisation number is missing and must be stated as the sender identity in the payment file.",
|
||||
"debtor_missing_link": "Open Settings → Invoicing",
|
||||
"debtor_missing_org_link": "Open Settings → Company",
|
||||
"history_title": "Payment files",
|
||||
"th_created": "Created",
|
||||
"th_count": "Count",
|
||||
|
||||
@@ -910,7 +910,9 @@
|
||||
"pain001_agreement_warning": "Filen är i ISO 20022-format (pain.001) och laddas upp i internetbanken. Vissa banker kräver filkommunikationsavtal; kontrollera att din bank tar emot filen i god tid före betaldagen.",
|
||||
"debtor_missing_iban": "Företagets IBAN saknas och behövs som avsändarkonto i betalfilen.",
|
||||
"debtor_missing_bic": "Företagsbankens BIC saknas och kunde inte härledas.",
|
||||
"debtor_missing_org": "Företagets organisationsnummer saknas och måste anges som avsändaridentitet i betalfilen.",
|
||||
"debtor_missing_link": "Öppna Inställningar → Fakturering",
|
||||
"debtor_missing_org_link": "Öppna Inställningar → Företag",
|
||||
"history_title": "Betalfiler",
|
||||
"th_created": "Skapad",
|
||||
"th_count": "Antal",
|
||||
|
||||
@@ -787,6 +787,8 @@ export interface SupplierPaymentBatchDebtor {
|
||||
org_number: string
|
||||
iban: string
|
||||
bic: string
|
||||
/** Absent on batches created before the Swedbank MIG fixes (2026-08-10). */
|
||||
bankgiro?: string | null
|
||||
}
|
||||
|
||||
export interface SupplierPaymentBatch {
|
||||
|
||||
Reference in New Issue
Block a user