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:
co-authored by
Jakob Wennberg
Claude Fable 5
parent
76b8d5c100
commit
798a76ed7a
@@ -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
@@ -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}`,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user