* fix(bookkeeping): settle unbound transactions on the company's single enabled cash account A transaction with no cash_account_id booked its bank leg on the hardcoded 1930 from the standard templates and category mappings even when the company's only bank account is e.g. 1920 (PlusGiro), while the booking dialogs previewed the right account via the client-side resolveAccount fallback. resolveSettlementAccount now mirrors that fallback: with a NULL cash_account_id it lists the company's enabled cash accounts and, when EXACTLY ONE matches the transaction's currency, settles there; zero or several candidates keep the 1930 fallback. The explicit-cash_account_id branch (including its throw-on-error path, issue #842) is byte-identical. Transaction currency is threaded into the categorize, batch-categorize, pending-operation edit, MCP staging, and invoice-inbox call sites; other callers get the SEK default. Forward-only: historical wrong verifikat are corrected only via the existing storno runbook (docs/SETTLEMENT_ACCOUNT_REMEDIATION.md). Fixes #1722 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SyDuePXxUFowaPBKpAv8SF * test: align duplicate-guard mock queue with combined pre-FY and settlement-fallback lookups The merge of main (PR #1828) into this branch combined two changes that each add one query to the categorize commit flow; the strictly ordered queued mock in the allow_duplicate test needed the cash_accounts listing entry inserted between the period lookup and the pre-FY clamp lookup. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SyDuePXxUFowaPBKpAv8SF --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
100 lines
4.0 KiB
TypeScript
100 lines
4.0 KiB
TypeScript
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
import type { Logger } from '@/lib/logger'
|
|
import { BookkeepingDatabaseError } from '@/lib/bookkeeping/errors'
|
|
|
|
const FALLBACK_ACCOUNT = '1930'
|
|
|
|
/**
|
|
* Resolve the BAS ledger account a transaction actually settles from/to.
|
|
*
|
|
* Never fall back to a company-wide "last used" setting (e.g.
|
|
* last_supplier_payment_account, written by the manual mark-paid
|
|
* private-funds flow): those reflect unrelated flows with no relationship
|
|
* to which bank account a specific transaction is linked to.
|
|
* cash_account_id -> cash_accounts.ledger_account is the only source of
|
|
* truth for a real transaction's settlement account.
|
|
*
|
|
* When the transaction has NO cash_account_id (legacy/unresolved rows),
|
|
* mirror the client-side resolveAccount (lib/cash-accounts/resolve-account.ts):
|
|
* if the company has EXACTLY ONE enabled cash account in the transaction's
|
|
* currency, that account is unambiguous and the bank leg belongs there.
|
|
* Without this, a company whose only bank account is e.g. 1920 got its
|
|
* booking dialogs previewing 1920 while the posted verifikat silently hit
|
|
* the hardcoded 1930 template leg (issue #1722). Zero or several candidate
|
|
* accounts keeps the historical 1930 fallback: guessing between real
|
|
* accounts is worse than the known-neutral default.
|
|
*/
|
|
export async function resolveSettlementAccount(
|
|
supabase: SupabaseClient,
|
|
companyId: string,
|
|
cashAccountId: string | null,
|
|
log: Logger,
|
|
currency: string = 'SEK',
|
|
): Promise<string> {
|
|
if (!cashAccountId) {
|
|
const { data: candidates, error: listError } = await supabase
|
|
.from('cash_accounts')
|
|
.select('ledger_account')
|
|
.eq('company_id', companyId)
|
|
.eq('enabled', true)
|
|
.eq('currency', currency)
|
|
.limit(2)
|
|
|
|
if (listError) {
|
|
// Unlike the explicit-cashAccountId branch below (which throws, #842),
|
|
// this path historically never queried at all and always returned 1930,
|
|
// so failing the whole request on a lookup error here would regress
|
|
// every unbound transaction, including ambiguous companies whose answer
|
|
// is 1930 anyway. Degrade to the historical fallback and warn.
|
|
log.warn('settlement-account currency fallback lookup failed; defaulting to 1930', {
|
|
companyId,
|
|
currency,
|
|
error: listError.message,
|
|
})
|
|
return FALLBACK_ACCOUNT
|
|
}
|
|
|
|
if (candidates?.length === 1) {
|
|
const ledgerAccount = candidates[0]?.ledger_account as string | null
|
|
if (ledgerAccount) return ledgerAccount
|
|
// ledger_account is NOT NULL in the schema; a hole here is a
|
|
// data-integrity gap that must not hide behind a plausible 1930 leg.
|
|
log.warn('settlement-account currency fallback row has no ledger_account; defaulting to 1930', {
|
|
companyId,
|
|
currency,
|
|
})
|
|
}
|
|
return FALLBACK_ACCOUNT
|
|
}
|
|
|
|
const { data, error } = await supabase
|
|
.from('cash_accounts')
|
|
.select('ledger_account')
|
|
.eq('id', cashAccountId)
|
|
.eq('company_id', companyId)
|
|
.maybeSingle()
|
|
|
|
if (error) {
|
|
// An EXPLICIT cash_account_id exists: it almost certainly resolves to a
|
|
// non-1930 account, so silently degrading to 1930 on a transient lookup
|
|
// failure risks the exact class of misbooking this helper exists to
|
|
// prevent, just triggered by infra flakiness instead of a stale setting.
|
|
// Fail the request instead: the caller can retry, whereas a wrongly
|
|
// booked verifikat needs a storno to correct (BFL 5 kap).
|
|
throw new BookkeepingDatabaseError('resolve_settlement_account', error.message)
|
|
}
|
|
|
|
// A transaction with a cash_account_id that resolves to no row, or a row
|
|
// with no ledger_account, is a data-integrity gap (not a normal "no cash
|
|
// account linked" case): the fallback fires silently otherwise, masking a
|
|
// bad cash_accounts row behind a plausible-looking 1930 verifikat.
|
|
if (!data?.ledger_account) {
|
|
log.warn('settlement-account lookup returned no ledger_account; defaulting to 1930', {
|
|
cashAccountId,
|
|
})
|
|
return FALLBACK_ACCOUNT
|
|
}
|
|
|
|
return data.ledger_account as string
|
|
}
|