fix(invoices): accept USD/GBP payment accounts without an IBAN (#1649)

Payment accounts per currency required an IBAN for every non-SEK
currency. USD (ABA routing number) and GBP (sort code) accounts have no
IBAN, so a Wise US or UK receiving account could only be saved by
pasting an IBAN from another currency, which then printed on the invoice
and misrouted the payment.

- InvoicePaymentAccount gains bank_code (routing number / sort code) and
  foreign_account_number; JSONB column, no migration.
- Rule, shared by the Zod schema, the client validation and
  hasUsableInvoicePaymentAccount: a foreign account is usable with an
  IBAN, or, only for NON_IBAN_CURRENCIES (USD, GBP), with bank_code +
  foreign_account_number + BIC. EUR/NOK/DKK still require IBAN.
- Settings: the two fields appear only for USD/GBP with the identifier
  named per currency (Routing number (ABA) / Sort code), a hint that IBAN
  may be left empty, and IBAN no longer marked required there.
- Invoice PDF renders the routing row with the same per-currency label
  plus the foreign account number, in both sv and en.

Reported via gnubok_feedback 2026-08-03.

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-17 22:23:36 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5
parent 76b8d5c100
commit 798a76ed7a
10 changed files with 323 additions and 13 deletions
+48
View File
@@ -1400,6 +1400,54 @@ describe('UpdateSettingsSchema', () => {
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,
+32 -8
View File
@@ -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}`,
})
}
})
@@ -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,
+39 -1
View File
@@ -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<InvoicePaymentAccount> | 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
+28
View File
@@ -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
<Text style={styles.paymentValue}>{company.swish}</Text>
</View>
)}
{/* Non-IBAN foreign routing (USD ABA / GBP sort code): the label
names the identifier the customer's bank asks for. */}
{company.bank_code && (
<View style={styles.paymentRow}>
<Text style={styles.paymentLabel}>
{invoice.currency === 'USD'
? L.routingNumber
: invoice.currency === 'GBP'
? L.sortCode
: L.bankCode}
</Text>
<Text style={styles.paymentValue}>{company.bank_code}</Text>
</View>
)}
{company.foreign_account_number && (
<View style={styles.paymentRow}>
<Text style={styles.paymentLabel}>{L.foreignAccount}</Text>
<Text style={styles.paymentValue}>{company.foreign_account_number}</Text>
</View>
)}
{company.iban && (
<View style={styles.paymentRow}>
<Text style={styles.paymentLabel}>{L.iban}</Text>