* 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
145 lines
4.0 KiB
TypeScript
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 }
|
|
}
|