"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>
256 lines
8.5 KiB
TypeScript
256 lines
8.5 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import {
|
|
InvoicePaymentAccountMissingError,
|
|
assertInvoicePaymentAccountForRender,
|
|
companyWithInvoicePaymentAccount,
|
|
describeMissingInvoicePaymentAccount,
|
|
isInvoicePaymentAccountCurrency,
|
|
hasRequiredInvoicePaymentAccount,
|
|
hasUsableInvoicePaymentAccount,
|
|
invoiceRequiresPaymentAccount,
|
|
resolveInvoicePaymentAccount,
|
|
} from '@/lib/invoices/payment-accounts'
|
|
import { makeInvoice } from '@/tests/helpers'
|
|
import type { CompanySettings } from '@/types'
|
|
|
|
function company(overrides: Partial<CompanySettings> = {}): CompanySettings {
|
|
return {
|
|
bank_name: 'Legacy bank',
|
|
clearing_number: '1234',
|
|
account_number: '1234567',
|
|
bankgiro: '123-4567',
|
|
plusgiro: null,
|
|
swish: null,
|
|
iban: 'SE0011111111111111111111',
|
|
bic: 'LEGASESS',
|
|
...overrides,
|
|
} as CompanySettings
|
|
}
|
|
|
|
describe('invoice payment accounts', () => {
|
|
it('uses legacy payment details for SEK only', () => {
|
|
const settings = company()
|
|
|
|
expect(resolveInvoicePaymentAccount(settings, 'SEK')?.iban).toBe('SE0011111111111111111111')
|
|
expect(resolveInvoicePaymentAccount(settings, 'EUR')).toBeNull()
|
|
})
|
|
|
|
it('selects the account matching the invoice currency', () => {
|
|
const settings = company({
|
|
invoice_payment_accounts: {
|
|
EUR: {
|
|
bank_name: 'EUR bank',
|
|
clearing_number: null,
|
|
account_number: null,
|
|
bankgiro: null,
|
|
plusgiro: null,
|
|
swish: null,
|
|
iban: 'SE0022222222222222222222',
|
|
bic: 'EURRSESS',
|
|
},
|
|
},
|
|
})
|
|
|
|
const rendered = companyWithInvoicePaymentAccount(settings, 'EUR')
|
|
expect(rendered.bank_name).toBe('EUR bank')
|
|
expect(rendered.iban).toBe('SE0022222222222222222222')
|
|
expect(rendered.bankgiro).toBeNull()
|
|
})
|
|
|
|
it('clears legacy SEK details when a foreign account is missing', () => {
|
|
const rendered = companyWithInvoicePaymentAccount(company(), 'EUR')
|
|
|
|
expect(rendered.iban).toBeNull()
|
|
expect(rendered.bankgiro).toBeNull()
|
|
})
|
|
|
|
it('requires an IBAN for a foreign-currency payment account', () => {
|
|
const withoutIban = resolveInvoicePaymentAccount(company({
|
|
invoice_payment_accounts: {
|
|
EUR: {
|
|
bank_name: 'EUR bank',
|
|
clearing_number: '1234',
|
|
account_number: '1234567',
|
|
bankgiro: null,
|
|
plusgiro: null,
|
|
swish: null,
|
|
iban: null,
|
|
bic: null,
|
|
},
|
|
},
|
|
}), 'EUR')
|
|
|
|
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,
|
|
account_number: null,
|
|
bankgiro: null,
|
|
plusgiro: null,
|
|
swish: null,
|
|
iban: null,
|
|
})
|
|
|
|
expect(() => assertInvoicePaymentAccountForRender(company(), 'EUR')).toThrow(
|
|
InvoicePaymentAccountMissingError,
|
|
)
|
|
expect(() => assertInvoicePaymentAccountForRender(emptySettings, 'SEK')).toThrow(
|
|
InvoicePaymentAccountMissingError,
|
|
)
|
|
expect(() => assertInvoicePaymentAccountForRender(company(), 'SEK')).not.toThrow()
|
|
})
|
|
|
|
it('requires payment accounts only for payable invoice documents', () => {
|
|
expect(invoiceRequiresPaymentAccount(makeInvoice())).toBe(true)
|
|
expect(invoiceRequiresPaymentAccount(makeInvoice({ credited_invoice_id: 'invoice-original' }))).toBe(false)
|
|
expect(invoiceRequiresPaymentAccount(makeInvoice({ document_type: 'delivery_note' }))).toBe(false)
|
|
expect(invoiceRequiresPaymentAccount(makeInvoice({ document_type: 'proforma' }))).toBe(false)
|
|
})
|
|
|
|
it('accepts non-payable documents without an account', () => {
|
|
const emptySettings = company({
|
|
clearing_number: null,
|
|
account_number: null,
|
|
bankgiro: null,
|
|
plusgiro: null,
|
|
swish: null,
|
|
iban: null,
|
|
})
|
|
|
|
expect(hasRequiredInvoicePaymentAccount(
|
|
emptySettings,
|
|
makeInvoice({ document_type: 'proforma' }),
|
|
)).toBe(true)
|
|
expect(hasRequiredInvoicePaymentAccount(emptySettings, makeInvoice())).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('describeMissingInvoicePaymentAccount (#2126)', () => {
|
|
it('SEK: names the domestic options and never talks about currency accounts', () => {
|
|
const { sv, en } = describeMissingInvoicePaymentAccount('SEK')
|
|
expect(sv).toContain('bankgiro')
|
|
expect(sv).toContain('plusgiro')
|
|
expect(sv).toContain('Swish')
|
|
expect(sv).toContain('Inställningar → Fakturering')
|
|
expect(sv).not.toMatch(/valuta/i)
|
|
expect(sv).not.toMatch(/IBAN/)
|
|
expect(en).toContain('bankgiro')
|
|
expect(en).toContain('Settings → Invoicing')
|
|
expect(en).not.toMatch(/currency/i)
|
|
})
|
|
|
|
it('EUR: asks for an IBAN account in that currency', () => {
|
|
const { sv, en } = describeMissingInvoicePaymentAccount('EUR')
|
|
expect(sv).toContain('EUR')
|
|
expect(sv).toContain('IBAN')
|
|
expect(sv).not.toContain('routing number')
|
|
expect(sv).toContain('Inställningar → Fakturering')
|
|
expect(en).toContain('EUR')
|
|
expect(en).toContain('IBAN')
|
|
})
|
|
|
|
it('USD/GBP: also offers the non-IBAN routing path', () => {
|
|
expect(describeMissingInvoicePaymentAccount('USD').sv).toContain('routing number')
|
|
expect(describeMissingInvoicePaymentAccount('GBP').sv).toContain('sort code')
|
|
expect(describeMissingInvoicePaymentAccount('GBP').en).toContain('sort code')
|
|
})
|
|
|
|
it('isInvoicePaymentAccountCurrency guards the details field', () => {
|
|
expect(isInvoicePaymentAccountCurrency('SEK')).toBe(true)
|
|
expect(isInvoicePaymentAccountCurrency('NOK')).toBe(true)
|
|
expect(isInvoicePaymentAccountCurrency('JPY')).toBe(false)
|
|
expect(isInvoicePaymentAccountCurrency(undefined)).toBe(false)
|
|
expect(isInvoicePaymentAccountCurrency(42)).toBe(false)
|
|
})
|
|
})
|