diff --git a/DECISIONS.md b/DECISIONS.md index 69dc7eb2..fc233875 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1046,3 +1046,4 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-17] 77xx nedskrivningar split per official BAS kopplingstabell in BOTH k2-mapper and ink2-engine (fältkod 7515: 7700-7739, 7750-7789, 7800-7899; 7516: 774x, 779x): agent feedback 2026-07-07 reported the K2 side; the INK2R side and the swedish-sru-filing reference table had the same whole-77xx-to-7516 error, verified against bas.se INK2_P1_intervall-240118.pdf before overriding the skill reference. NE-bilaga mappings deliberately untouched (NE has no separate omsättningstillgångar line). [2026-08-17] MCP feedback loop = local /loop-feedback-triage appending dev_docs/mcp_feedback_digest.md + small PRs, NOT a GitHub-issue digest or Resend email: closes the loops.md backlog item blocked since 07-09 on a "channel decision". Issues stay founder-authorised; the digest is the read surface. gnubok_feedback reply copy no longer promises weekly aggregation (it was never true); tool advertised in server instructions + agent briefing (feedback_channel), where it was previously discoverable only by scanning tools/list. +[2026-08-17] Non-IBAN foreign payment accounts (USD/GBP): added generic bank_code + foreign_account_number to InvoicePaymentAccount (JSONB, no migration) instead of per-country fields (routing_number, sort_code, bsb); rule = IBAN OR (bank_code + foreign_account_number + BIC), only for NON_IBAN_CURRENCIES, label per currency. Chosen over a field per country: the Currency union only carries USD/GBP among non-IBAN systems, and one generic pair keeps the PDF/settings/schema surface small; extend NON_IBAN_CURRENCIES + bankCodeLabelKey when AUD/CAD land. Agent feedback 2026-08-03. diff --git a/components/settings/InvoicePaymentAccountsSettings.tsx b/components/settings/InvoicePaymentAccountsSettings.tsx index 043554d4..5eb0338a 100644 --- a/components/settings/InvoicePaymentAccountsSettings.tsx +++ b/components/settings/InvoicePaymentAccountsSettings.tsx @@ -21,6 +21,9 @@ import { formatBankgiroNumber, validateBankgiroNumber, validatePlusgiroNumber } import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' import { INVOICE_PAYMENT_ACCOUNT_CURRENCIES, + bankCodeLabelKey, + hasNonIbanForeignRouting, + isNonIbanCurrency, legacySekInvoicePaymentAccount, normalizeInvoicePaymentAccount, } from '@/lib/invoices/payment-accounts' @@ -46,6 +49,8 @@ const EMPTY_ACCOUNT: InvoicePaymentAccount = { swish: null, iban: null, bic: null, + bank_code: null, + foreign_account_number: null, } function initialAccounts( @@ -233,8 +238,25 @@ export function InvoicePaymentAccountsSettings({ if (account.bic && !/^[A-Z]{6}[A-Z0-9]{2}([A-Z0-9]{3})?$/.test(account.bic)) { return t('validation_bic', { currency }) } + if (account.bank_code && !/^\d{2,3}(-?\d{2,3}){1,2}$|^\d{6,9}$/.test(account.bank_code)) { + return t('validation_bank_code', { currency }) + } + if ( + account.foreign_account_number + && !/^[A-Za-z0-9-]{4,34}$/.test(account.foreign_account_number) + ) { + return t('validation_foreign_account_number', { currency }) + } + // Foreign account: IBAN, or (non-IBAN banking system) bank code + + // account number + BIC. Same rule as InvoicePaymentAccountsSchema. if (currency !== 'SEK' && !account.iban) { - return t('validation_foreign_iban', { currency }) + if (isNonIbanCurrency(currency)) { + if (!hasNonIbanForeignRouting(account)) { + return t('validation_foreign_non_iban', { currency }) + } + } else { + return t('validation_foreign_iban', { currency }) + } } } return null @@ -457,9 +479,42 @@ export function InvoicePaymentAccountsSettings({ className="max-w-40 flex-none tabular-nums" /> + {isNonIbanCurrency(activeCurrency) && ( + <> + + updateField('bank_code', event.target.value.replace(/[^\d-]/g, ''))} + placeholder={activeCurrency === 'USD' ? '021000021' : '12-34-56'} + className="max-w-40 flex-none tabular-nums" + /> + + + updateField('foreign_account_number', event.target.value.replace(/\s/g, ''))} + className="max-w-56 flex-none tabular-nums" + /> + + {t('non_iban_hint', { currency: activeCurrency })} + + )} { expect(result.success).toBe(true) }) + it('accepts a USD payment account with routing number + account number + BIC and no IBAN', () => { + const result = UpdateSettingsSchema.safeParse({ + invoice_payment_accounts: { + USD: { + bank_name: 'Wise US Inc', + bic: 'trwius35xxx', + bank_code: '084 009 519', + foreign_account_number: '9600 0012 3456 7890', + }, + }, + }) + + expect(result.success).toBe(true) + if (result.success) { + const usd = result.data.invoice_payment_accounts?.USD + expect(usd?.bic).toBe('TRWIUS35XXX') + expect(usd?.bank_code).toBe('084009519') + expect(usd?.foreign_account_number).toBe('9600001234567890') + } + }) + + it('accepts a GBP payment account with a dashed sort code', () => { + const result = UpdateSettingsSchema.safeParse({ + invoice_payment_accounts: { + GBP: { bic: 'NWBKGB2L', bank_code: '60-16-13', foreign_account_number: '31926819' }, + }, + }) + expect(result.success).toBe(true) + }) + + it('rejects a USD payment account with neither IBAN nor the full routing triple', () => { + const result = UpdateSettingsSchema.safeParse({ + invoice_payment_accounts: { + USD: { bank_code: '084009519', foreign_account_number: '9600001234567890' }, + }, + }) + expect(result.success).toBe(false) + }) + + it('still requires an IBAN for an EUR payment account even with a routing triple', () => { + const result = UpdateSettingsSchema.safeParse({ + invoice_payment_accounts: { + EUR: { bic: 'DEUTDEFF', bank_code: '37040044', foreign_account_number: '0532013000' }, + }, + }) + expect(result.success).toBe(false) + }) + it('accepts null when clearing the legacy SEK bank account mirror', () => { const result = UpdateSettingsSchema.safeParse({ bank_name: null, diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index ec4376ff..60b24f29 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -20,8 +20,9 @@ import { deductionTypeForWorkType, normalizeHouseworkType, } from '@/lib/invoices/rot-rut-rules' +import { NON_IBAN_CURRENCIES } from '@/lib/invoices/payment-accounts' import { PERSONAL_NUMBER_INPUT_RE } from '@/lib/customers/mask-personal-number' -import type { AuditAction } from '@/types' +import type { AuditAction, Currency } from '@/types' import type { BankFileFormatId } from '@/lib/import/bank-file/types' // ============================================================ @@ -1927,19 +1928,42 @@ const InvoicePaymentAccountSchema = z.object({ .nullable() .optional() .or(z.literal('')), + // Foreign non-IBAN routing (USD ABA routing number, GBP sort code): digits + // with optional dashes, 6-9 digits after stripping (ABA = 9, sort code = 6). + bank_code: z.string() + .transform((value) => value.replace(/\s/g, '')) + .pipe(z.string().regex(/^\d{2,3}(-?\d{2,3}){1,2}$|^\d{6,9}$/, 'Ogiltig bankkod')) + .nullable() + .optional() + .or(z.literal('')), + // Foreign account number: alphanumeric, distinct from the Swedish + // clearing+account pair (account_number is digits-only 6-12). + foreign_account_number: z.string() + .transform((value) => value.replace(/\s/g, '')) + .pipe(z.string().regex(/^[A-Za-z0-9-]{4,34}$/, 'Ogiltigt kontonummer')) + .nullable() + .optional() + .or(z.literal('')), }) const InvoicePaymentAccountsSchema = z .partialRecord(CurrencySchema, InvoicePaymentAccountSchema) .superRefine((accounts, ctx) => { for (const [currency, account] of Object.entries(accounts)) { - if (currency !== 'SEK' && account && !account.iban) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: [currency, 'iban'], - message: `IBAN krävs för betalningskonto i ${currency}`, - }) - } + if (currency === 'SEK' || !account) continue + if (account.iban) continue + // Non-IBAN banking systems (US, UK): bank code + account number + BIC + // identifies the account. Requiring an IBAN there forced users to paste + // one from another currency, which then printed on the invoice. + const nonIban = NON_IBAN_CURRENCIES.includes(currency as Currency) + if (nonIban && account.bank_code && account.foreign_account_number && account.bic) continue + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: [currency, 'iban'], + message: nonIban + ? `Ange IBAN eller bankkod, kontonummer och BIC/SWIFT för betalningskontot i ${currency}` + : `IBAN krävs för betalningskonto i ${currency}`, + }) } }) diff --git a/lib/invoices/__tests__/payment-accounts.test.ts b/lib/invoices/__tests__/payment-accounts.test.ts index c7bdcb76..800991e9 100644 --- a/lib/invoices/__tests__/payment-accounts.test.ts +++ b/lib/invoices/__tests__/payment-accounts.test.ts @@ -81,6 +81,94 @@ describe('invoice payment accounts', () => { expect(hasUsableInvoicePaymentAccount(withoutIban, 'EUR')).toBe(false) }) + it('accepts a USD account identified by routing number + account number + BIC without an IBAN', () => { + // gnubok_feedback 2026-08-03: a Wise US receiving account has no IBAN + // (US banking does not use them); the only way past the old validation + // was pasting an IBAN from another currency, which then printed on the + // invoice and misrouted the payment. + const usd = resolveInvoicePaymentAccount(company({ + invoice_payment_accounts: { + USD: { + bank_name: 'Wise US Inc', + clearing_number: null, + account_number: null, + bankgiro: null, + plusgiro: null, + swish: null, + iban: null, + bic: 'TRWIUS35XXX', + bank_code: '084009519', + foreign_account_number: '9600001234567890', + }, + }, + }), 'USD') + + expect(hasUsableInvoicePaymentAccount(usd, 'USD')).toBe(true) + expect(usd?.bank_code).toBe('084009519') + expect(usd?.foreign_account_number).toBe('9600001234567890') + + const rendered = companyWithInvoicePaymentAccount(company({ + invoice_payment_accounts: { + USD: { + bank_name: 'Wise US Inc', + clearing_number: null, + account_number: null, + bankgiro: null, + plusgiro: null, + swish: null, + iban: null, + bic: 'TRWIUS35XXX', + bank_code: '084009519', + foreign_account_number: '9600001234567890', + }, + }, + }), 'USD') + expect(rendered.iban).toBeNull() + expect(rendered.bank_code).toBe('084009519') + expect(rendered.foreign_account_number).toBe('9600001234567890') + }) + + it('still requires an IBAN for a non-IBAN-currency account that lacks the routing triple', () => { + const partial = resolveInvoicePaymentAccount(company({ + invoice_payment_accounts: { + GBP: { + bank_name: 'UK bank', + clearing_number: null, + account_number: null, + bankgiro: null, + plusgiro: null, + swish: null, + iban: null, + bic: null, + bank_code: '12-34-56', + foreign_account_number: '12345678', + }, + }, + }), 'GBP') + // Sort code + account number but no BIC: not payable from abroad. + expect(hasUsableInvoicePaymentAccount(partial, 'GBP')).toBe(false) + }) + + it('does not accept the routing triple for an IBAN currency', () => { + const eur = resolveInvoicePaymentAccount(company({ + invoice_payment_accounts: { + EUR: { + bank_name: 'EUR bank', + clearing_number: null, + account_number: null, + bankgiro: null, + plusgiro: null, + swish: null, + iban: null, + bic: 'DEUTDEFF', + bank_code: '37040044', + foreign_account_number: '0532013000', + }, + }, + }), 'EUR') + expect(hasUsableInvoicePaymentAccount(eur, 'EUR')).toBe(false) + }) + it('blocks payable rendering in every currency without a usable account', () => { const emptySettings = company({ clearing_number: null, diff --git a/lib/invoices/payment-accounts.ts b/lib/invoices/payment-accounts.ts index 737865fc..2ad23dd8 100644 --- a/lib/invoices/payment-accounts.ts +++ b/lib/invoices/payment-accounts.ts @@ -23,8 +23,37 @@ const PAYMENT_FIELDS: readonly (keyof InvoicePaymentAccount)[] = [ 'swish', 'iban', 'bic', + 'bank_code', + 'foreign_account_number', ] +/** + * Currencies whose domestic banking systems do not use IBAN. A payment + * account in one of these may be identified by bank_code + account number + + * BIC instead of an IBAN (USD: ABA routing number, GBP: sort code). Any + * other non-SEK currency still requires IBAN. Extend here (and the + * bank_code label map) when a non-IBAN currency is added to Currency. + */ +export const NON_IBAN_CURRENCIES: readonly Currency[] = ['USD', 'GBP'] + +export function isNonIbanCurrency(currency: Currency): boolean { + return NON_IBAN_CURRENCIES.includes(currency) +} + +/** The routing identifier's name per currency, for labels and messages. */ +export function bankCodeLabelKey(currency: Currency): 'routing_number' | 'sort_code' | 'bank_code' { + switch (currency) { + case 'USD': return 'routing_number' + case 'GBP': return 'sort_code' + default: return 'bank_code' + } +} + +/** True when a foreign account is fully identified WITHOUT an IBAN. */ +export function hasNonIbanForeignRouting(account: Partial | null): boolean { + return !!(account?.bank_code && account?.foreign_account_number && account?.bic) +} + function clean(value: string | null | undefined): string | null { const trimmed = value?.trim() return trimmed ? trimmed : null @@ -42,6 +71,8 @@ export function legacySekInvoicePaymentAccount( swish: clean(company.swish), iban: clean(company.iban), bic: clean(company.bic), + bank_code: null, + foreign_account_number: null, } } @@ -57,6 +88,8 @@ export function normalizeInvoicePaymentAccount( swish: clean(account.swish), iban: clean(account.iban)?.replace(/\s/g, '').toUpperCase() ?? null, bic: clean(account.bic)?.replace(/\s/g, '').toUpperCase() ?? null, + bank_code: clean(account.bank_code)?.replace(/\s/g, '') ?? null, + foreign_account_number: clean(account.foreign_account_number)?.replace(/\s/g, '') ?? null, } } @@ -74,7 +107,12 @@ export function hasUsableInvoicePaymentAccount( currency: Currency, ): boolean { if (!account) return false - if (currency !== 'SEK') return !!account.iban + if (currency !== 'SEK') { + // A foreign account is usable with an IBAN, or (for non-IBAN banking + // systems) with a bank code + account number + BIC. Anything else would + // print an invoice the customer cannot pay from. + return !!account.iban || (isNonIbanCurrency(currency) && hasNonIbanForeignRouting(account)) + } return !!( account.iban || account.bankgiro diff --git a/lib/invoices/pdf-template.tsx b/lib/invoices/pdf-template.tsx index 08ace221..5c8be8d1 100644 --- a/lib/invoices/pdf-template.tsx +++ b/lib/invoices/pdf-template.tsx @@ -91,6 +91,10 @@ const LABELS = { swish: 'Swish:', iban: 'IBAN:', bic: 'BIC/SWIFT:', + routingNumber: 'Routing number (ABA):', + sortCode: 'Sort code:', + bankCode: 'Bankkod:', + foreignAccount: 'Kontonummer:', ocr: 'OCR/Referens:', paymentReference: 'Betalningsreferens:', invoiceNumber: 'Fakturanummer:', @@ -159,6 +163,10 @@ const LABELS = { swish: 'Swish:', iban: 'IBAN:', bic: 'BIC/SWIFT:', + routingNumber: 'Routing number (ABA):', + sortCode: 'Sort code:', + bankCode: 'Bank code:', + foreignAccount: 'Account number:', ocr: 'Reference:', paymentReference: 'Payment reference:', invoiceNumber: 'Invoice number:', @@ -1120,6 +1128,26 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN {company.swish} )} + {/* Non-IBAN foreign routing (USD ABA / GBP sort code): the label + names the identifier the customer's bank asks for. */} + {company.bank_code && ( + + + {invoice.currency === 'USD' + ? L.routingNumber + : invoice.currency === 'GBP' + ? L.sortCode + : L.bankCode} + + {company.bank_code} + + )} + {company.foreign_account_number && ( + + {L.foreignAccount} + {company.foreign_account_number} + + )} {company.iban && ( {L.iban} diff --git a/messages/en.json b/messages/en.json index 1b8c8ae3..ad8d257e 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2161,7 +2161,7 @@ }, "settings_invoice_payment_accounts": { "heading": "Payment accounts by currency", - "description": "The invoice automatically shows the account matching its currency. A foreign-currency account must have an IBAN before the invoice can be sent.", + "description": "The invoice automatically shows the account matching its currency. A foreign-currency account must have an IBAN, or for USD/GBP a bank code, account number and BIC/SWIFT, before the invoice can be sent.", "currency_tabs_label": "Configured currencies", "add_currency_label": "Add currency", "add_currency_placeholder": "Select currency", @@ -2178,6 +2178,11 @@ "swish_label": "Swish", "iban_label": "IBAN", "bic_label": "BIC/SWIFT", + "routing_number": "Routing number (ABA)", + "sort_code": "Sort code", + "bank_code": "Bank code", + "foreign_account_number_label": "Account number (foreign)", + "non_iban_hint": "{currency} accounts often have no IBAN. Enter the bank code, account number and BIC/SWIFT instead; IBAN can be left empty.", "required_suffix": "(required)", "validation_title": "Check the payment account", "validation_clearing": "The clearing number for {currency} must contain 4 to 5 digits.", @@ -2188,6 +2193,9 @@ "validation_iban": "The IBAN for {currency} is invalid.", "validation_bic": "The BIC/SWIFT for {currency} is invalid.", "validation_foreign_iban": "Enter an IBAN for the {currency} payment account.", + "validation_foreign_non_iban": "Enter an IBAN, or a bank code, account number and BIC/SWIFT, for the {currency} payment account.", + "validation_bank_code": "The bank code for {currency} is invalid.", + "validation_foreign_account_number": "The account number for {currency} is invalid.", "conflict_title": "Payment accounts changed elsewhere", "conflict_description": "Reload the latest saved values before saving. Your unsaved edits will be discarded.", "reload_server_values": "Reload saved values", diff --git a/messages/sv.json b/messages/sv.json index bbce30f8..6beee226 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -2161,7 +2161,7 @@ }, "settings_invoice_payment_accounts": { "heading": "Betalningskonton per valuta", - "description": "Fakturan visar automatiskt kontot som matchar fakturans valuta. Ett utländskt konto måste ha IBAN för att fakturan ska kunna skickas.", + "description": "Fakturan visar automatiskt kontot som matchar fakturans valuta. Ett utländskt konto måste ha IBAN, eller för USD/GBP bankkod, kontonummer och BIC/SWIFT, för att fakturan ska kunna skickas.", "currency_tabs_label": "Konfigurerade valutor", "add_currency_label": "Lägg till valuta", "add_currency_placeholder": "Välj valuta", @@ -2178,6 +2178,11 @@ "swish_label": "Swish", "iban_label": "IBAN", "bic_label": "BIC/SWIFT", + "routing_number": "Routing number (ABA)", + "sort_code": "Sort code", + "bank_code": "Bankkod", + "foreign_account_number_label": "Kontonummer (utländskt)", + "non_iban_hint": "Konton i {currency} saknar ofta IBAN. Ange då bankkod, kontonummer och BIC/SWIFT i stället; IBAN kan lämnas tomt.", "required_suffix": "(obligatoriskt)", "validation_title": "Kontrollera betalningskontot", "validation_clearing": "Clearingnumret för {currency} måste vara 4 till 5 siffror.", @@ -2188,6 +2193,9 @@ "validation_iban": "IBAN för {currency} är ogiltigt.", "validation_bic": "BIC/SWIFT för {currency} är ogiltigt.", "validation_foreign_iban": "Ange IBAN för betalningskontot i {currency}.", + "validation_foreign_non_iban": "Ange IBAN, eller bankkod, kontonummer och BIC/SWIFT, för betalningskontot i {currency}.", + "validation_bank_code": "Bankkoden för {currency} är ogiltig.", + "validation_foreign_account_number": "Kontonumret för {currency} är ogiltigt.", "conflict_title": "Betalningskontona har ändrats någon annanstans", "conflict_description": "Läs in de senast sparade värdena innan du sparar. Dina osparade ändringar tas bort.", "reload_server_values": "Läs in sparade värden", diff --git a/types/index.ts b/types/index.ts index 0800dd89..8f2b39c2 100644 --- a/types/index.ts +++ b/types/index.ts @@ -217,6 +217,14 @@ export interface InvoicePaymentAccount { swish: string | null iban: string | null bic: string | null + /** + * Foreign non-IBAN routing: ABA routing number (USD), sort code (GBP), + * BSB (AUD) or a comparable national bank code. Only meaningful together + * with foreign_account_number + bic on a non-SEK account. + */ + bank_code?: string | null + /** Foreign account number for non-IBAN countries (US/UK/AU/CA style). */ + foreign_account_number?: string | null } // Profile (extends auth.users) @@ -335,6 +343,10 @@ export interface CompanySettings { swish: string | null iban: string | null bic: string | null + // Foreign non-IBAN routing, only ever populated on the render-time copy + // produced by companyWithInvoicePaymentAccount (never a DB column). + bank_code?: string | null + foreign_account_number?: string | null // Invoice payment instructions keyed by the currency they can receive. // Legacy bank fields above remain the SEK fallback for older companies. invoice_payment_accounts?: Partial>