Files
accounted/lib/invoices/payment-accounts.ts
T
Mattsson 466e55a015 Fix/invoice delivery and payment accounts (#1116)
* fix: reconcile annual reports with final closing entries

* test: cover annual report depreciation and VAT balances

* Merge remote-tracking branch 'origin/main' into fix/usr-fdbck-ch

* fix: show exact invoice delivery details

* fix: use currency account in invoice emails

* fix: address invoice delivery review feedback

* fix: harden invoice delivery and payment accounts

* test: assert RLS-denied zero-row updates

* fix: close remaining invoice compliance gaps

* fix: harden invoice archive authorization

* fix: close invoice delivery review findings

* fix: verify delivery finalization results

* fix: cap combined invoice email recipients

* fix: close final invoice compliance findings

* fix: prevent stale payment account saves

* test: prove invoice delivery isolation

* fix: close invoice privacy review findings

* test: normalize delivery retention dates
2026-07-23 09:54:02 +02:00

145 lines
4.0 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',
]
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),
}
}
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,
}
}
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') return !!account.iban
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,
)
}
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 }
}