diff --git a/DECISIONS.md b/DECISIONS.md index 3cfcd882..8fcb3cda 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -857,3 +857,4 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-10] Atom seed migrations gained a version-downgrade guard in the generator (#1483): each seed is a full 108-atom upsert built from its branch's tree, so two parallel PRs each carrying a seed meant the last-applied one silently reverted the other's atom bodies while skills:check stayed green (it compares files to the manifest, never the DB). The ON CONFLICT now skips rows whose registry version is newer than the seed's. Dance-event VAT stayed guidance-plus-descriptor only: no rate cutover logic in the engine, since 6% already exists as a treatment and the entry date decides the rate the user picks. [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. diff --git a/lib/payments/__tests__/batch-service.test.ts b/lib/payments/__tests__/batch-service.test.ts index 559b3231..14cb973c 100644 --- a/lib/payments/__tests__/batch-service.test.ts +++ b/lib/payments/__tests__/batch-service.test.ts @@ -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 = {}) { 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', diff --git a/lib/payments/__tests__/pain001-supplier.test.ts b/lib/payments/__tests__/pain001-supplier.test.ts index 64ad613e..6e3c9f8b 100644 --- a/lib/payments/__tests__/pain001-supplier.test.ts +++ b/lib/payments/__tests__/pain001-supplier.test.ts @@ -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(/[\s\S]*?<\/Cdtr>/)![0] + expect(creditor).not.toContain('') + }) + + it('carries the supplier town on IBAN-debited payments when known', () => { + const xml = generateSupplierPain001( + debtor, + [bgPayment({ payeeCity: 'Göteborg' })], + options, + ) + const creditor = xml.match(/[\s\S]*?<\/Cdtr>/)![0] + expect(creditor).toContain('Göteborg') + expect(creditor.indexOf('')).toBeLessThan(creditor.indexOf('')) + }) + + it('carries the company town on the debtor when known', () => { + const xml = generateSupplierPain001( + { ...debtor, city: 'Stockholm' }, + [bgPayment()], + options, + ) + const debtorBlock = xml.match(/[\s\S]*?<\/Dbtr>/)![0] + expect(debtorBlock).toContain('Stockholm') + expect(debtorBlock.indexOf('')).toBeLessThan(debtorBlock.indexOf('')) + }) + it('transliterates disallowed characters in names (MIG character set)', () => { const xml = generateSupplierPain001( debtor, diff --git a/lib/payments/batch-service.ts b/lib/payments/batch-service.ts index 2939cb46..b78211d5 100644 --- a/lib/payments/batch-service.ts +++ b/lib/payments/batch-service.ts @@ -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 }, diff --git a/lib/payments/pain001-supplier.ts b/lib/payments/pain001-supplier.ts index 450613f8..32d10c03 100644 --- a/lib/payments/pain001-supplier.ts +++ b/lib/payments/pain001-supplier.ts @@ -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(` ${date}`) lines.push(' ') lines.push(` ${escapeXml(debtorName)}`) + // XSD order in PostalAddress6: TwnNm before Ctry; PstlAdr before Id. + if (debtor.city?.trim()) { + lines.push(' ') + lines.push(` ${escapeXml(sanitizeText(debtor.city.trim()))}`) + lines.push(' SE') + lines.push(' ') + } lines.push(' ') lines.push(' ') lines.push(` ${escapeXml(orgDigits)}`) @@ -198,7 +212,7 @@ export function generateSupplierPain001( lines.push(' ') lines.push(` ${formatDecimal(payment.amount)}`) lines.push(' ') - pushCreditor(lines, payment) + pushCreditor(lines, payment, bgnrDebit) pushRemittance(lines, payment.reference, payment.amount) lines.push(' ') } @@ -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(' ') lines.push(' ') lines.push(` ${escapeXml(sanitizeText(payment.payeeName))}`) - // 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(' ') - lines.push(' SE') - lines.push(' ') + // 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(' ') + } lines.push(' ') lines.push(' ') lines.push(' ') diff --git a/supabase/migrations/20260810191118_supplier_payment_batch_items_payee_city.sql b/supabase/migrations/20260810191118_supplier_payment_batch_items_payee_city.sql new file mode 100644 index 00000000..7f867bcf --- /dev/null +++ b/supabase/migrations/20260810191118_supplier_payment_batch_items_payee_city.sql @@ -0,0 +1,12 @@ +-- Swedbank MIG rule PFH_222 (Validex round 2, 2026-08-10): when a creditor +-- postal address is present, TwnNm is mandatory from November 2026. IBAN- +-- debited payments require the address (rule 237), so items snapshot the +-- supplier's city at creation, same immutability rules as the other payee +-- fields. Nullable: suppliers without a city keep working (BGNR-debited +-- payments carry no creditor address at all). +-- +-- pg-test: covered-by tests/pg/supplier-payment-batches.pg.test.ts +ALTER TABLE public.supplier_payment_batch_items + ADD COLUMN payee_city text; + +NOTIFY pgrst, 'reload schema'; diff --git a/types/index.ts b/types/index.ts index e1c47916..22836a56 100644 --- a/types/index.ts +++ b/types/index.ts @@ -789,6 +789,8 @@ export interface SupplierPaymentBatchDebtor { bic: string /** Absent on batches created before the Swedbank MIG fixes (2026-08-10). */ bankgiro?: string | null + /** Company town for Dbtr/PstlAdr; absent on pre-TownName-fix batches. */ + city?: string | null } export interface SupplierPaymentBatch { @@ -827,6 +829,8 @@ export interface SupplierPaymentBatchItem { payee_clearing: string | null payee_account: string | null payee_name: string + /** Supplier town at creation; feeds Cdtr/PstlAdr/TwnNm on IBAN-debited payments. */ + payee_city: string | null reference_type: SupplierPaymentBatchReferenceType reference: string created_at: string