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:
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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';
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user