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
+1
View File
@@ -858,3 +858,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[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.
[2026-08-10] Validex round 3 falsified the round-2 reading: rule 237 (creditor PstlAdr mandatory) fires on the BGNR-to-BGNR path too, so rule 020's "required unless DbtrAcct is BGNR" only governs the Ctry element, not the address as a whole. Creditor PstlAdr is now emitted on every payment, TwnNm from the payee_city snapshot when known, and the preview warns (payee_city_missing) when the supplier register lacks a city, since 237 + 222 together make a town effectively mandatory at Swedbank and a Ctry-only address is rejected today, not from November.
@@ -29,7 +29,7 @@ interface PreviewLine {
payment_date: string
payee: { type: string; label: string }
reference: { type: 'ocr' | 'invoice_number'; value: string }
warnings: Array<'unattested' | 'already_batched' | 'ocr_invalid'>
warnings: Array<'unattested' | 'already_batched' | 'ocr_invalid' | 'payee_city_missing'>
active_batch_id: string | null
}
@@ -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>')
+1
View File
@@ -889,6 +889,7 @@
"warning_unattested": "Not approved",
"warning_already_batched": "In payment file",
"warning_ocr_invalid": "Invalid OCR",
"warning_payee_city_missing": "Supplier town missing",
"confirm_already_batched": "One or more invoices are already part of an active payment file. I understand that a new file creates another payment for them.",
"excluded_title": "Not included in the payment file",
"excluded_reason_not_payable": "cannot be paid in its current status",
+1
View File
@@ -889,6 +889,7 @@
"warning_unattested": "Ej attesterad",
"warning_already_batched": "I betalfil",
"warning_ocr_invalid": "Ogiltigt OCR-nr",
"warning_payee_city_missing": "Ort saknas på leverantören",
"confirm_already_batched": "En eller flera fakturor ingår redan i en aktiv betalfil. Jag förstår att en ny fil skapar ytterligare en betalning för dem.",
"excluded_title": "Ingår inte i betalfilen",
"excluded_reason_not_payable": "kan inte betalas i nuvarande status",