fix(payments): creditor address on every payment (Validex round 3) (#1509)

Rule 237 fires on the BGNR-to-BGNR path too, so the round-2 reading of
rule 020 was wrong: it only relaxes the Ctry element, not the address.
Cdtr now always carries PstlAdr (TwnNm from the payee_city snapshot when
known, Ctry SE), and the preview warns when the supplier register lacks
a city, since rules 237 + 222 together make a town effectively mandatory
at Swedbank.

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:54:07 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5
parent 4655f3da48
commit e45218bcc6
8 changed files with 42 additions and 29 deletions
@@ -15,6 +15,7 @@ const supplier = {
bank_account: null,
clearing_number: null,
account_number: null,
city: 'Stockholm',
}
function invoice(overrides: Partial<BatchInvoiceFacts> = {}): BatchInvoiceFacts {
@@ -140,6 +141,13 @@ describe('evaluateInvoiceForBatch', () => {
})
})
it('warns when the supplier has no town (Swedbank address rules)', () => {
const result = evaluateInvoiceForBatch(invoice(), { ...supplier, city: null }, {
today: TODAY,
})
expect(result.eligible && result.warnings).toContain('payee_city_missing')
})
it('carries a clean OCR through as the structured reference', () => {
const result = evaluateInvoiceForBatch(invoice(), supplier, { today: TODAY })
expect(result.eligible && result.reference).toEqual({ type: 'ocr', value: VALID_OCR })
@@ -135,14 +135,15 @@ describe('generateSupplierPain001', () => {
expect(debtorAccounts[0]).not.toContain('BGNR')
})
it('omits the creditor address on BGNR-debited payments (TwnNm rule)', () => {
it('carries the creditor address on the BGNR path too (rule 237 is absolute)', () => {
const xml = generateSupplierPain001(
{ ...debtor, bankgiro: '9912346' },
[bgPayment()],
[bgPayment({ payeeCity: 'Veddige' })],
options,
)
const creditor = xml.match(/<Cdtr>[\s\S]*?<\/Cdtr>/)![0]
expect(creditor).not.toContain('<PstlAdr>')
expect(creditor).toContain('<TwnNm>Veddige</TwnNm>')
expect(creditor).toContain('<Ctry>SE</Ctry>')
})
it('carries the supplier town on IBAN-debited payments when known', () => {
+10 -2
View File
@@ -42,7 +42,14 @@ export type BatchExclusionReason =
| 'payee_missing'
| 'payee_invalid'
export type BatchItemWarning = 'unattested' | 'already_batched' | 'ocr_invalid'
export type BatchItemWarning =
| 'unattested'
| 'already_batched'
| 'ocr_invalid'
// Swedbank rejects a creditor address without a town (Validex rules
// 237 + 222), and the address itself is mandatory; warn so the user fills
// in the supplier's city before the bank bounces the file.
| 'payee_city_missing'
export interface BatchInvoiceFacts {
id: string
@@ -76,7 +83,7 @@ export type BatchInvoiceEvaluation =
export function evaluateInvoiceForBatch(
invoice: BatchInvoiceFacts,
supplier: SupplierPayeeSource,
supplier: SupplierPayeeSource & { city?: string | null },
options: BatchEvaluationOptions,
): BatchInvoiceEvaluation {
if (invoice.is_credit_note) return { eligible: false, reason: 'credit_note' }
@@ -98,6 +105,7 @@ export function evaluateInvoiceForBatch(
const activeBatchId = options.activeBatchIdByInvoice?.get(invoice.id) ?? null
if (activeBatchId) warnings.push('already_batched')
if (ocrInvalid) warnings.push('ocr_invalid')
if (!supplier.city?.trim()) warnings.push('payee_city_missing')
return {
eligible: true,
+16 -23
View File
@@ -27,12 +27,13 @@
* 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.
* - 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.
* - Creditor postal address ALWAYS (Validex round 3, rule PFH_237 fires on
* the BGNR path too, overriding what rule 020's phrasing suggested), and
* a present PstlAdr must carry TwnNm (rule PFH_222, enforced now): the
* supplier's town when known, plus Ctry SE (v1 is domestic-only). A payee
* without a town yields a Ctry-only address that Swedbank rejects, so the
* preview warns when the supplier register lacks a city. 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.
@@ -212,7 +213,7 @@ export function generateSupplierPain001(
lines.push(' <Amt>')
lines.push(` <InstdAmt Ccy="SEK">${formatDecimal(payment.amount)}</InstdAmt>`)
lines.push(' </Amt>')
pushCreditor(lines, payment, bgnrDebit)
pushCreditor(lines, payment)
pushRemittance(lines, payment.reference, payment.amount)
lines.push(' </CdtTrfTxInf>')
}
@@ -227,11 +228,7 @@ export function generateSupplierPain001(
}
/** XSD order within CdtTrfTxInf: CdtrAgt before Cdtr before CdtrAcct. */
function pushCreditor(
lines: string[],
payment: SupplierPain001Payment,
bgnrDebit: boolean,
): void {
function pushCreditor(lines: string[], payment: SupplierPain001Payment): void {
const { payee } = payment
let memberId: string
@@ -267,18 +264,14 @@ function pushCreditor(
lines.push(' </CdtrAgt>')
lines.push(' <Cdtr>')
lines.push(` <Nm>${escapeXml(sanitizeText(payment.payeeName))}</Nm>`)
// 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>')
// Always (Validex round 3, rule 237 fires on every path): the supplier's
// town when known and country SE (v1 scope). XSD order: TwnNm before Ctry.
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>')