fix(payments): creditor address per Swedbank TwnNm rule (Validex round 2) (#1508)

A present PstlAdr must carry TwnNm from November 2026 (PFH_222), so
BGNR-to-BGNR payments now carry no creditor address at all (rule 020
requires none there), IBAN-debited payments carry the supplier's town
(snapshotted as payee_city) plus Ctry SE, and the debtor address comes
from company settings, clearing the info-level rule 236 as well.

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 21:19:44 +02:00
committed by GitHub
parent fea5dfd1f9
commit 4655f3da48
7 changed files with 99 additions and 15 deletions
+1
View File
@@ -857,3 +857,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[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.
[2026-08-10] Validex round 2 (1 fatal left): a present PstlAdr must carry TwnNm from November 2026 (rule 222), and the round-1 fix had added Ctry-only creditor addresses everywhere. Resolution follows Swedbank's own conditionality instead of stuffing TwnNm in unconditionally: BGNR-to-BGNR payments carry NO creditor address (rule 020 does not require one there), IBAN-debited payments carry the supplier's town (new nullable supplier_payment_batch_items.payee_city snapshot) plus Ctry SE, and the debtor gains PstlAdr TwnNm/Ctry from company_settings.city, which also clears the info-level rule 236. Rule 183 (BIC must be SWEDSESS) fires only because the sandbox debtor is a fake SEB account; real Swedbank customers resolve SWEDSESS from their own settings.
@@ -16,6 +16,7 @@ const companyRow = { name: 'Testbolaget AB', org_number: '556677-8899' }
const settingsRow = {
company_name: 'Testbolaget AB',
org_number: '556677-8899',
city: 'Stockholm',
iban: 'SE3550000000054910000003',
bic: 'ESSESESS',
bankgiro: '991-2346',
@@ -37,6 +38,7 @@ function invoiceRow(overrides: Record<string, unknown> = {}) {
supplier: {
id: 'sup-1',
name: 'Derome Bygg & Industri AB',
city: 'Veddige',
bankgiro: '5050-1055',
plusgiro: null,
bank_account: null,
@@ -216,6 +218,7 @@ describe('createSupplierPaymentBatch', () => {
iban: 'SE3550000000054910000003',
bic: 'ESSESESS',
bankgiro: '9912346',
city: 'Stockholm',
},
})
const msgId = batchInsert.msg_id as string
@@ -235,6 +238,7 @@ describe('createSupplierPaymentBatch', () => {
payee_type: 'bankgiro',
payee_bankgiro: '50501055',
payee_name: 'Derome Bygg & Industri AB',
payee_city: 'Veddige',
reference_type: 'invoice_number',
reference: 'CD3014794407',
})
@@ -436,6 +440,7 @@ describe('renderSupplierPaymentBatchFile', () => {
payee_clearing: null,
payee_account: null,
payee_name: 'Derome Bygg & Industri AB',
payee_city: null,
reference_type: 'invoice_number',
reference: 'CD3014794407',
created_at: '2026-08-10T12:00:00Z',
@@ -135,6 +135,38 @@ describe('generateSupplierPain001', () => {
expect(debtorAccounts[0]).not.toContain('BGNR')
})
it('omits the creditor address on BGNR-debited payments (TwnNm rule)', () => {
const xml = generateSupplierPain001(
{ ...debtor, bankgiro: '9912346' },
[bgPayment()],
options,
)
const creditor = xml.match(/<Cdtr>[\s\S]*?<\/Cdtr>/)![0]
expect(creditor).not.toContain('<PstlAdr>')
})
it('carries the supplier town on IBAN-debited payments when known', () => {
const xml = generateSupplierPain001(
debtor,
[bgPayment({ payeeCity: 'Göteborg' })],
options,
)
const creditor = xml.match(/<Cdtr>[\s\S]*?<\/Cdtr>/)![0]
expect(creditor).toContain('<TwnNm>Göteborg</TwnNm>')
expect(creditor.indexOf('<TwnNm>')).toBeLessThan(creditor.indexOf('<Ctry>'))
})
it('carries the company town on the debtor when known', () => {
const xml = generateSupplierPain001(
{ ...debtor, city: 'Stockholm' },
[bgPayment()],
options,
)
const debtorBlock = xml.match(/<Dbtr>[\s\S]*?<\/Dbtr>/)![0]
expect(debtorBlock).toContain('<TwnNm>Stockholm</TwnNm>')
expect(debtorBlock.indexOf('<PstlAdr>')).toBeLessThan(debtorBlock.indexOf('<Id>'))
})
it('transliterates disallowed characters in names (MIG character set)', () => {
const xml = generateSupplierPain001(
debtor,
+9 -3
View File
@@ -34,13 +34,13 @@ import { generateSupplierPain001, type SupplierPain001Payment } from './pain001-
import type { SupplierPaymentBatch, SupplierPaymentBatchItem } from '@/types'
type InvoiceRow = BatchInvoiceFacts & {
supplier: (SupplierPayeeSource & { id: string; name: string }) | null
supplier: (SupplierPayeeSource & { id: string; name: string; city: string | null }) | null
}
const INVOICE_SELECT =
'id, status, approved_at, due_date, remaining_amount, currency, is_credit_note, ' +
'payment_reference, supplier_invoice_number, ' +
'supplier:suppliers(id, name, bankgiro, plusgiro, bank_account, clearing_number, account_number)'
'supplier:suppliers(id, name, city, bankgiro, plusgiro, bank_account, clearing_number, account_number)'
export interface BatchDebtor {
name: string
@@ -49,6 +49,8 @@ export interface BatchDebtor {
bic: string
/** Company bankgiro digits; enables the BGNR-to-BGNR debit Swedbank wants. */
bankgiro: string | null
/** Company town; Dbtr/PstlAdr/TwnNm (mandatory from Nov 2026 when present). */
city: string | null
}
export type DebtorResolution =
@@ -71,7 +73,7 @@ export async function resolveBatchDebtor(
supabase.from('companies').select('name, org_number').eq('id', companyId).single(),
supabase
.from('company_settings')
.select('company_name, org_number, iban, bic, bankgiro, clearing_number, bank_name')
.select('company_name, org_number, city, iban, bic, bankgiro, clearing_number, bank_name')
.eq('company_id', companyId)
.single(),
])
@@ -101,6 +103,7 @@ export async function resolveBatchDebtor(
iban,
bic,
bankgiro,
city: settings?.city?.trim() || null,
},
}
}
@@ -315,6 +318,7 @@ export async function createSupplierPaymentBatch(
payee_clearing: payee.type === 'bank_account' ? payee.clearing : null,
payee_account: payee.type === 'bank_account' ? payee.account : null,
payee_name: invoice.supplier.name,
payee_city: invoice.supplier.city?.trim() || null,
reference_type: evaluation.reference.type,
reference: evaluation.reference.value,
})
@@ -401,6 +405,7 @@ export function renderSupplierPaymentBatchFile(
account: item.payee_account ?? '',
},
payeeName: item.payee_name,
payeeCity: item.payee_city ?? null,
amount: item.amount,
paymentDate: item.payment_date,
reference: { type: item.reference_type, value: item.reference },
@@ -414,6 +419,7 @@ export function renderSupplierPaymentBatchFile(
iban: debtor.iban,
bic: debtor.bic,
bankgiro: debtor.bankgiro ?? null,
city: debtor.city ?? null,
},
payments,
{ messageId: batch.msg_id, createdAt: batch.created_at },
+36 -12
View File
@@ -26,10 +26,13 @@
* - 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).
* (DbtrAcct Othr/BGNR); other payees are debited from the IBAN.
* - Creditor postal address only where required (Validex round 2, rule
* PFH_222): a present PstlAdr must carry TwnNm from November 2026, so
* BGNR-debited payments carry NO creditor address (rule 020 does not
* require one there), and IBAN-debited payments carry TwnNm (the
* supplier's town, when known) plus Ctry SE (v1 is domestic-only). The
* debtor address rides along the same way from company settings.
* - InitgPty and Dbtr always carry OrgId (Validex PFH_002: InitgPty
* other/Id must be stated); the batch service refuses to create a batch
* for a company without an organisationsnummer.
@@ -64,11 +67,15 @@ export interface SupplierPain001Debtor {
bic: string
/** Company bankgiro (digits); enables the BGNR-to-BGNR debit Swedbank wants. */
bankgiro?: string | null
/** Company town; emitted as Dbtr/PstlAdr/TwnNm when present. */
city?: string | null
}
export interface SupplierPain001Payment {
payee: SupplierPayee
payeeName: string
/** Supplier town; Cdtr/PstlAdr/TwnNm on IBAN-debited payments when known. */
payeeCity?: string | null
amount: number
/** YYYY-MM-DD requested execution date. */
paymentDate: string
@@ -161,6 +168,13 @@ export function generateSupplierPain001(
lines.push(` <ReqdExctnDt>${date}</ReqdExctnDt>`)
lines.push(' <Dbtr>')
lines.push(` <Nm>${escapeXml(debtorName)}</Nm>`)
// XSD order in PostalAddress6: TwnNm before Ctry; PstlAdr before Id.
if (debtor.city?.trim()) {
lines.push(' <PstlAdr>')
lines.push(` <TwnNm>${escapeXml(sanitizeText(debtor.city.trim()))}</TwnNm>`)
lines.push(' <Ctry>SE</Ctry>')
lines.push(' </PstlAdr>')
}
lines.push(' <Id>')
lines.push(' <OrgId>')
lines.push(` <Othr><Id>${escapeXml(orgDigits)}</Id></Othr>`)
@@ -198,7 +212,7 @@ export function generateSupplierPain001(
lines.push(' <Amt>')
lines.push(` <InstdAmt Ccy="SEK">${formatDecimal(payment.amount)}</InstdAmt>`)
lines.push(' </Amt>')
pushCreditor(lines, payment)
pushCreditor(lines, payment, bgnrDebit)
pushRemittance(lines, payment.reference, payment.amount)
lines.push(' </CdtTrfTxInf>')
}
@@ -213,7 +227,11 @@ export function generateSupplierPain001(
}
/** XSD order within CdtTrfTxInf: CdtrAgt before Cdtr before CdtrAcct. */
function pushCreditor(lines: string[], payment: SupplierPain001Payment): void {
function pushCreditor(
lines: string[],
payment: SupplierPain001Payment,
bgnrDebit: boolean,
): void {
const { payee } = payment
let memberId: string
@@ -249,12 +267,18 @@ function pushCreditor(lines: string[], payment: SupplierPain001Payment): void {
lines.push(' </CdtrAgt>')
lines.push(' <Cdtr>')
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>')
// Creditor address only where required (Validex rules 020/237 vs 222): a
// BGNR-to-BGNR payment needs none, and a present PstlAdr must carry TwnNm
// from November 2026, so an address is emitted only on IBAN-debited
// payments: the supplier's town when known, and country SE (v1 scope).
if (!bgnrDebit) {
lines.push(' <PstlAdr>')
if (payment.payeeCity?.trim()) {
lines.push(` <TwnNm>${escapeXml(sanitizeText(payment.payeeCity.trim()))}</TwnNm>`)
}
lines.push(' <Ctry>SE</Ctry>')
lines.push(' </PstlAdr>')
}
lines.push(' </Cdtr>')
lines.push(' <CdtrAcct>')
lines.push(' <Id>')
@@ -0,0 +1,12 @@
-- Swedbank MIG rule PFH_222 (Validex round 2, 2026-08-10): when a creditor
-- postal address is present, TwnNm is mandatory from November 2026. IBAN-
-- debited payments require the address (rule 237), so items snapshot the
-- supplier's city at creation, same immutability rules as the other payee
-- fields. Nullable: suppliers without a city keep working (BGNR-debited
-- payments carry no creditor address at all).
--
-- pg-test: covered-by tests/pg/supplier-payment-batches.pg.test.ts
ALTER TABLE public.supplier_payment_batch_items
ADD COLUMN payee_city text;
NOTIFY pgrst, 'reload schema';
+4
View File
@@ -789,6 +789,8 @@ export interface SupplierPaymentBatchDebtor {
bic: string
/** Absent on batches created before the Swedbank MIG fixes (2026-08-10). */
bankgiro?: string | null
/** Company town for Dbtr/PstlAdr; absent on pre-TownName-fix batches. */
city?: string | null
}
export interface SupplierPaymentBatch {
@@ -827,6 +829,8 @@ export interface SupplierPaymentBatchItem {
payee_clearing: string | null
payee_account: string | null
payee_name: string
/** Supplier town at creation; feeds Cdtr/PstlAdr/TwnNm on IBAN-debited payments. */
payee_city: string | null
reference_type: SupplierPaymentBatchReferenceType
reference: string
created_at: string