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
@@ -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"
|
||||
/>
|
||||
</SettingsRow>
|
||||
{isNonIbanCurrency(activeCurrency) && (
|
||||
<>
|
||||
<SettingsRow
|
||||
label={t(bankCodeLabelKey(activeCurrency))}
|
||||
htmlFor={`payment-bank-code-${activeCurrency}`}
|
||||
align="baseline"
|
||||
>
|
||||
<SettingsInput
|
||||
id={`payment-bank-code-${activeCurrency}`}
|
||||
inputMode="numeric"
|
||||
maxLength={11}
|
||||
value={value(activeAccount, 'bank_code')}
|
||||
onChange={(event) => updateField('bank_code', event.target.value.replace(/[^\d-]/g, ''))}
|
||||
placeholder={activeCurrency === 'USD' ? '021000021' : '12-34-56'}
|
||||
className="max-w-40 flex-none tabular-nums"
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
label={t('foreign_account_number_label')}
|
||||
htmlFor={`payment-foreign-account-${activeCurrency}`}
|
||||
align="baseline"
|
||||
>
|
||||
<SettingsInput
|
||||
id={`payment-foreign-account-${activeCurrency}`}
|
||||
maxLength={34}
|
||||
value={value(activeAccount, 'foreign_account_number')}
|
||||
onChange={(event) => updateField('foreign_account_number', event.target.value.replace(/\s/g, ''))}
|
||||
className="max-w-56 flex-none tabular-nums"
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRowNote>{t('non_iban_hint', { currency: activeCurrency })}</SettingsRowNote>
|
||||
</>
|
||||
)}
|
||||
<SettingsRow
|
||||
label={
|
||||
activeCurrency !== 'SEK'
|
||||
activeCurrency !== 'SEK' && !isNonIbanCurrency(activeCurrency)
|
||||
? `${t('iban_label')} ${t('required_suffix')}`
|
||||
: t('iban_label')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user