diff --git a/DECISIONS.md b/DECISIONS.md index 8fcb3cda..d3319ea6 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -858,3 +858,4 @@ One line per decision: `[YYYY-MM-DD] : `. 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. diff --git a/components/supplier-invoices/PaymentFileDialog.tsx b/components/supplier-invoices/PaymentFileDialog.tsx index 8a65b72e..2e5fe142 100644 --- a/components/supplier-invoices/PaymentFileDialog.tsx +++ b/components/supplier-invoices/PaymentFileDialog.tsx @@ -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 } diff --git a/lib/payments/__tests__/batch-eligibility.test.ts b/lib/payments/__tests__/batch-eligibility.test.ts index 76bd6569..aa40a7e4 100644 --- a/lib/payments/__tests__/batch-eligibility.test.ts +++ b/lib/payments/__tests__/batch-eligibility.test.ts @@ -15,6 +15,7 @@ const supplier = { bank_account: null, clearing_number: null, account_number: null, + city: 'Stockholm', } function invoice(overrides: Partial = {}): 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 }) diff --git a/lib/payments/__tests__/pain001-supplier.test.ts b/lib/payments/__tests__/pain001-supplier.test.ts index 6e3c9f8b..d508f934 100644 --- a/lib/payments/__tests__/pain001-supplier.test.ts +++ b/lib/payments/__tests__/pain001-supplier.test.ts @@ -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(/[\s\S]*?<\/Cdtr>/)![0] - expect(creditor).not.toContain('') + expect(creditor).toContain('Veddige') + expect(creditor).toContain('SE') }) it('carries the supplier town on IBAN-debited payments when known', () => { diff --git a/lib/payments/batch-eligibility.ts b/lib/payments/batch-eligibility.ts index a71c308a..2b47f320 100644 --- a/lib/payments/batch-eligibility.ts +++ b/lib/payments/batch-eligibility.ts @@ -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, diff --git a/lib/payments/pain001-supplier.ts b/lib/payments/pain001-supplier.ts index 32d10c03..c2ba712a 100644 --- a/lib/payments/pain001-supplier.ts +++ b/lib/payments/pain001-supplier.ts @@ -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(' ') lines.push(` ${formatDecimal(payment.amount)}`) lines.push(' ') - pushCreditor(lines, payment, bgnrDebit) + pushCreditor(lines, payment) pushRemittance(lines, payment.reference, payment.amount) lines.push(' ') } @@ -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(' ') lines.push(' ') lines.push(` ${escapeXml(sanitizeText(payment.payeeName))}`) - // 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(' ') - if (payment.payeeCity?.trim()) { - lines.push(` ${escapeXml(sanitizeText(payment.payeeCity.trim()))}`) - } - lines.push(' SE') - lines.push(' ') + // 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(' ') + if (payment.payeeCity?.trim()) { + lines.push(` ${escapeXml(sanitizeText(payment.payeeCity.trim()))}`) } + lines.push(' SE') + lines.push(' ') lines.push(' ') lines.push(' ') lines.push(' ') diff --git a/messages/en.json b/messages/en.json index 3ff75191..fc8e9e65 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/sv.json b/messages/sv.json index 7757821f..f103f87a 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -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",