diff --git a/DECISIONS.md b/DECISIONS.md index 3d0565bb..3cfcd882 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -856,3 +856,4 @@ One line per decision: `[YYYY-MM-DD] : `. 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. diff --git a/components/supplier-invoices/PaymentFileDialog.tsx b/components/supplier-invoices/PaymentFileDialog.tsx index 0a6efd87..8a65b72e 100644 --- a/components/supplier-invoices/PaymentFileDialog.tsx +++ b/components/supplier-invoices/PaymentFileDialog.tsx @@ -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({ ) : (
{!preview.debtor_ok && ( - + {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')} )} diff --git a/lib/payments/__tests__/batch-service.test.ts b/lib/payments/__tests__/batch-service.test.ts index db09c4ba..559b3231 100644 --- a/lib/payments/__tests__/batch-service.test.ts +++ b/lib/payments/__tests__/batch-service.test.ts @@ -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', () => { diff --git a/lib/payments/__tests__/pain001-supplier.test.ts b/lib/payments/__tests__/pain001-supplier.test.ts index 4349e5bd..64ad613e 100644 --- a/lib/payments/__tests__/pain001-supplier.test.ts +++ b/lib/payments/__tests__/pain001-supplier.test.ts @@ -80,14 +80,81 @@ describe('generateSupplierPain001', () => { expect(xml).toContain('BBAN') }) - 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(//g)).toHaveLength(1) expect(xml).toContain('SCOR') expect(xml).toContain('12345678') + // Swedbank MIG (Validex PFH_217): Strd must carry RfrdDocAmt. + expect(xml).toContain('737.50') + expect(xml.indexOf('')).toBeLessThan(xml.indexOf('')) expect(xml).not.toContain('') }) + 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('') + expect(xml).toContain('SE') + expect(xml.match(//g)!.length).toBeGreaterThanOrEqual(2) + expect(xml).toContain('5566778899') + }) + + 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(//g)).toHaveLength(2) + const debtorAccounts = xml.match(/[\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( + '9912346', + ) + 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(/[\s\S]*?<\/DbtrAcct>/g) ?? [] + expect(debtorAccounts).toHaveLength(1) + expect(debtorAccounts[0]).toContain(`${debtor.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('Demokafe + Creme AB') + }) + + 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('Cafe Ångby') + }) + 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(/[^<]*-P2<\/PmtInfId>/)).not.toBeNull() }) - it('escapes XML special characters in names', () => { - const xml = generateSupplierPain001(debtor, [bgPayment()], options) - expect(xml).toContain('Derome Bygg & Industri AB') + 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('Derome Bygg + Industri Åängö AB') }) it('keeps all ids within Max35Text with the suffix intact', () => { diff --git a/lib/payments/batch-service.ts b/lib/payments/batch-service.ts index 73b4b4a9..2939cb46 100644 --- a/lib/payments/batch-service.ts +++ b/lib/payments/batch-service.ts @@ -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 }, ) diff --git a/lib/payments/pain001-supplier.ts b/lib/payments/pain001-supplier.ts index 49db5510..450613f8 100644 --- a/lib/payments/pain001-supplier.ts +++ b/lib/payments/pain001-supplier.ts @@ -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() + // 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 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('') @@ -105,22 +135,22 @@ export function generateSupplierPain001( lines.push(` ${payments.length}`) lines.push(` ${formatDecimal(totalAmount)}`) lines.push(' ') - lines.push(` ${escapeXml(debtor.name)}`) - if (orgDigits) { - lines.push(' ') - lines.push(' ') - lines.push(` ${escapeXml(orgDigits)}`) - lines.push(' ') - 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 < 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(' ') lines.push(` ${escapeXml(suffixId(msgId, `-P${g + 1}`))}`) @@ -130,18 +160,23 @@ export function generateSupplierPain001( lines.push(` ${formatDecimal(groupTotal)}`) lines.push(` ${date}`) lines.push(' ') - lines.push(` ${escapeXml(debtor.name)}`) - if (orgDigits) { - lines.push(' ') - lines.push(' ') - lines.push(` ${escapeXml(orgDigits)}`) - lines.push(' ') - lines.push(' ') - } + lines.push(` ${escapeXml(debtorName)}`) + lines.push(' ') + lines.push(' ') + lines.push(` ${escapeXml(orgDigits)}`) + lines.push(' ') + lines.push(' ') lines.push(' ') lines.push(' ') lines.push(' ') - lines.push(` ${escapeXml(debtor.iban)}`) + 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(' ') @@ -164,7 +199,7 @@ export function generateSupplierPain001( lines.push(` ${formatDecimal(payment.amount)}`) lines.push(' ') pushCreditor(lines, payment) - pushRemittance(lines, payment.reference) + pushRemittance(lines, payment.reference, payment.amount) lines.push(' ') } @@ -213,7 +248,13 @@ function pushCreditor(lines: string[], payment: SupplierPain001Payment): void { lines.push(' ') lines.push(' ') lines.push(' ') - lines.push(` ${escapeXml(payment.payeeName)}`) + lines.push(` ${escapeXml(sanitizeText(payment.payeeName))}`) + // 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(' ') + lines.push(' SE') + lines.push(' ') lines.push(' ') lines.push(' ') lines.push(' ') @@ -225,10 +266,15 @@ function pushCreditor(lines: string[], payment: SupplierPain001Payment): void { lines.push(' ') } -function pushRemittance(lines: string[], reference: PaymentReference): void { +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') @@ -237,7 +283,7 @@ function pushRemittance(lines: string[], reference: PaymentReference): void { lines.push(' ') lines.push(' ') } else { - lines.push(` ${escapeXml(reference.value.slice(0, USTRD_MAX))}`) + lines.push(` ${escapeXml(sanitizeText(reference.value).slice(0, USTRD_MAX))}`) } lines.push(' ') } @@ -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 = { + 'é': '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, '&') diff --git a/messages/en.json b/messages/en.json index 7ccfec96..3ff75191 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/sv.json b/messages/sv.json index 2f763602..7757821f 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -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", diff --git a/types/index.ts b/types/index.ts index d64590c8..e1c47916 100644 --- a/types/index.ts +++ b/types/index.ts @@ -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 {