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
co-authored by Jakob Wennberg Claude Fable 5
parent fea5dfd1f9
commit 4655f3da48
7 changed files with 99 additions and 15 deletions
@@ -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>')