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:
co-authored by
Jakob Wennberg
Claude Fable 5
parent
fea5dfd1f9
commit
4655f3da48
@@ -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>')
|
||||
|
||||
Reference in New Issue
Block a user