Files
accounted/lib/invoices/payment-accounts.ts
T
ea45e9dc2f fix(invoices): say what payment detail is missing, per currency (#2126) (#2139)
"Fakturan saknar ett betalningskonto för vald valuta" read as a
foreign-currency account when the invoice was in SEK and the gap was
simply the company's bankgiro; the remediation line also asked for an
IBAN, which SEK does not need. A Visma-migrated user marking invoices
as sent hit this and went looking for a valutakonto.

- describeMissingInvoicePaymentAccount(currency) in
  lib/invoices/payment-accounts.ts: SEK names bankgiro, plusgiro, Swish
  or bank account; other currencies ask for an IBAN account in that
  currency (USD/GBP also offer routing number / sort code + BIC). Both
  point at Inställningar → Fakturering.
- getErrorMessage branches on INVOICE_SEND_PAYMENT_ACCOUNT_MISSING +
  details.currency (every dashboard route already sends it), before the
  English registry shortcut so both locales get the specific text.
- Registry entry rewritten currency-neutral for consumers without
  details (API, MCP): bankgiro/plusgiro/Swish/bankkonto for SEK, IBAN
  otherwise; remediation no longer says IBAN for everything.
- Staged-operation commit path uses the helper directly.

Tests: helper per currency, client mapping sv/en and the no-details
fallback.

Closes #2126


Claude-Session: https://claude.ai/code/session_01WFhSQWzu5SyXB6kG5ActZc

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-01 22:38:01 +02:00

216 lines
7.6 KiB
TypeScript

import type {
CompanySettings,
Currency,
Invoice,
InvoicePaymentAccount,
} from '@/types'
export const INVOICE_PAYMENT_ACCOUNT_CURRENCIES: readonly Currency[] = [
'SEK',
'EUR',
'USD',
'GBP',
'NOK',
'DKK',
]
const PAYMENT_FIELDS: readonly (keyof InvoicePaymentAccount)[] = [
'bank_name',
'clearing_number',
'account_number',
'bankgiro',
'plusgiro',
'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
}
export function legacySekInvoicePaymentAccount(
company: Pick<CompanySettings, keyof InvoicePaymentAccount>,
): InvoicePaymentAccount {
return {
bank_name: clean(company.bank_name),
clearing_number: clean(company.clearing_number),
account_number: clean(company.account_number),
bankgiro: clean(company.bankgiro),
plusgiro: clean(company.plusgiro),
swish: clean(company.swish),
iban: clean(company.iban),
bic: clean(company.bic),
bank_code: null,
foreign_account_number: null,
}
}
export function normalizeInvoicePaymentAccount(
account: Partial<InvoicePaymentAccount>,
): InvoicePaymentAccount {
return {
bank_name: clean(account.bank_name),
clearing_number: clean(account.clearing_number),
account_number: clean(account.account_number),
bankgiro: clean(account.bankgiro),
plusgiro: clean(account.plusgiro),
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,
}
}
export function resolveInvoicePaymentAccount(
company: CompanySettings,
currency: Currency,
): InvoicePaymentAccount | null {
const configured = company.invoice_payment_accounts?.[currency]
if (configured) return normalizeInvoicePaymentAccount(configured)
return currency === 'SEK' ? legacySekInvoicePaymentAccount(company) : null
}
export function hasUsableInvoicePaymentAccount(
account: InvoicePaymentAccount | null,
currency: Currency,
): boolean {
if (!account) return false
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
|| account.plusgiro
|| account.swish
|| (account.clearing_number && account.account_number)
)
}
export function invoiceRequiresPaymentAccount(
invoice: Pick<Invoice, 'credited_invoice_id' | 'document_type'>,
): boolean {
return !invoice.credited_invoice_id
&& invoice.document_type !== 'delivery_note'
&& invoice.document_type !== 'proforma'
}
export function hasRequiredInvoicePaymentAccount(
company: CompanySettings,
invoice: Pick<Invoice, 'credited_invoice_id' | 'currency' | 'document_type'>,
): boolean {
return !invoiceRequiresPaymentAccount(invoice)
|| hasUsableInvoicePaymentAccount(
resolveInvoicePaymentAccount(company, invoice.currency),
invoice.currency,
)
}
/**
* What "payment account missing" means for THIS currency, in the user's
* words. The registry entry for INVOICE_SEND_PAYMENT_ACCOUNT_MISSING has to
* stay currency-neutral; on a SEK invoice its "betalningskonto för vald
* valuta" read as a foreign-currency account when the gap was simply the
* company's bankgiro (#2126). Pure: shared by the server envelopes, the
* staged-operation commit path and the client-side error mapper.
*/
export function describeMissingInvoicePaymentAccount(
currency: Currency,
): { sv: string; en: string } {
if (currency === 'SEK') {
return {
sv: 'Fakturan saknar betalningsuppgifter: företaget har inget bankgiro, plusgiro, Swish-nummer eller bankkonto att skriva på fakturan. Lägg till ett under Inställningar → Fakturering och försök igen.',
en: 'The invoice has no payment details: the company has no bankgiro, plusgiro, Swish number or bank account to print on the invoice. Add one under Inställningar → Fakturering (Settings → Invoicing) and try again.',
}
}
const routingSv = isNonIbanCurrency(currency)
? `, eller med ${currency === 'USD' ? 'routing number' : 'sort code'}, kontonummer och BIC,`
: ''
const routingEn = isNonIbanCurrency(currency)
? `, or with ${currency === 'USD' ? 'routing number' : 'sort code'}, account number and BIC,`
: ''
return {
sv: `Fakturan är i ${currency}, men företaget saknar ett betalningskonto för ${currency}. Lägg till ett konto med IBAN${routingSv} för ${currency} under Inställningar → Fakturering och försök igen.`,
en: `The invoice is in ${currency}, but the company has no ${currency} payment account. Add an account with an IBAN${routingEn} for ${currency} under Inställningar → Fakturering (Settings → Invoicing) and try again.`,
}
}
export function isInvoicePaymentAccountCurrency(value: unknown): value is Currency {
return typeof value === 'string' && (INVOICE_PAYMENT_ACCOUNT_CURRENCIES as readonly string[]).includes(value)
}
export class InvoicePaymentAccountMissingError extends Error {
readonly code = 'INVOICE_SEND_PAYMENT_ACCOUNT_MISSING'
readonly currency: Currency
constructor(currency: Currency) {
super(`Invoice payment account is missing for ${currency}.`)
this.name = 'InvoicePaymentAccountMissingError'
this.currency = currency
}
}
export function assertInvoicePaymentAccountForRender(
company: CompanySettings,
currency: Currency,
): void {
if (
!hasUsableInvoicePaymentAccount(
resolveInvoicePaymentAccount(company, currency),
currency,
)
) {
throw new InvoicePaymentAccountMissingError(currency)
}
}
/**
* Return invoice render settings with only the matching payment account.
* Foreign invoices never inherit the legacy SEK payment details.
*/
export function companyWithInvoicePaymentAccount(
company: CompanySettings,
currency: Currency,
): CompanySettings {
const account = resolveInvoicePaymentAccount(company, currency)
const updates = Object.fromEntries(
PAYMENT_FIELDS.map((field) => [field, account?.[field] ?? null]),
) as Pick<CompanySettings, keyof InvoicePaymentAccount>
return { ...company, ...updates }
}