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