fix(transactions): stop defaulting supplier-invoice payment account to a stale private-funds setting (#985)
* fix(transactions): stop defaulting supplier-invoice payment account to a stale private-funds setting match-supplier-invoice (POST + preview) defaulted the credited cash account from company_settings.last_supplier_payment_account, a sticky setting written by the manual mark-paid "betald med privata medel" flow. Once that setting held 2893 (skuld till aktieägare) from an unrelated private payment, every later match against a real bank transaction reused it instead of the transaction's actual bank account, silently booking genuine bank payments as shareholder-loan repayments. Resolve the credit account from the matched transaction's own cash_account_id -> cash_accounts.ledger_account instead (falling back to 1930 when unlinked), mirroring the existing settlement-account lookup in transactions/[id]/categorize/route.ts. last_supplier_payment_account is no longer read by either route. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: Jonas Flodén <jonas@floden.nu> * refactor(transactions): extract shared settlement-account resolution helper Dedupe the identical cash_account_id -> ledger_account lookup across match-supplier-invoice (POST + preview) and categorize into resolveSettlementAccount, per CodeRabbit's nitpick on PR #985. Pure extraction, no behavior change. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: Jonas Flodén <jonas@floden.nu> * test(transactions): cover settlement-account lookup-error and preview parity gaps Adds the two test cases CodeRabbit flagged as missing on PR #985: - POST match-supplier-invoice: cash_accounts lookup errors, falls back to 1930 and warns (previously unexercised). - preview match-supplier-invoice: linked cash account other than 1930 (parity with the equivalent POST-route test). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: Jonas Flodén <jonas@floden.nu> * fix(transactions): thread resolved settlement account into FX/cash-method supplier-payment branches Closes the remaining items from the Swedish-accounting-compliance bot review on PR #985: - match-supplier-invoice/route.ts computed paymentAccount via resolveSettlementAccount but only passed it into the pure-SEK clearing branch; the FX branch (createSupplierInvoicePaymentEntry) and cash-method branch (createSupplierInvoiceCashEntry) still defaulted to 1930 internally even though both already accepted the parameter. - resolveSettlementAccount now also warns (and falls back to 1930) when cash_account_id resolves to a row with no ledger_account, not just on a hard query error. - Documents company_settings.last_supplier_payment_account's scope via a column comment: it must never be read to resolve a matched transaction's settlement account. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: Jonas Flodén <jonas@floden.nu> * fix(bookkeeping): abort instead of silently defaulting to 1930 when settlement-account lookup errors Compliance-bot finding on PR #987 (applies equally to #985/#986, shared helper): resolveSettlementAccount treated "no cash_account_id" and "lookup threw a real DB error" the same way -- warn and fall back to 1930. An explicit cash_account_id almost certainly resolves to a non-1930 account, so a transient failure masking it risked the exact class of misbooking this whole PR series exists to fix, just triggered by infra flakiness instead of a stale setting. Now throws BookkeepingDatabaseError on a genuine query error; every caller already runs under withRouteContext/withApiV1 (or the pending- operations dispatcher), whose existing catch-all already converts any isBookkeepingError() throw into the correct structured 500 -- no caller changes needed. The "row found but ledger_account empty" case stays warn+fallback (data-integrity gap, not a query failure). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: Jonas Flodén <jonas@floden.nu> * test(bookkeeping): use rejects.toBeInstanceOf for settlement-account error assertion Addresses CodeRabbit nitpick from the 2026-07-12 review round: matching BookkeepingDatabaseError via a `constructor` key in toMatchObject is non-idiomatic; toBeInstanceOf is the standard vitest assertion for this. Signed-off-by: Jonas Flodén <jonas@floden.nu> * docs: scope FX/cash-method paymentAccount gap note to /api/v1 and MCP routes CodeRabbit flagged the #1000 reference on PR #985 as ambiguous — the main match-supplier-invoice route's FX/cash-method branches already thread paymentAccount (per the prior entry), so the still-open gap only applies to the /api/v1 and MCP-facing route. Signed-off-by: Jonas Flodén <jonas@floden.nu> --------- Signed-off-by: Jonas Flodén <jonas@floden.nu> Co-authored-by: Jakob Wennberg <jakob.wennberg@gmail.com>
This commit is contained in:
co-authored by
Jakob Wennberg
parent
1e19099945
commit
528c53ffe7
@@ -0,0 +1,67 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { createMockSupabase } from '@/tests/helpers'
|
||||
import { resolveSettlementAccount } from '../settlement-account'
|
||||
import { BookkeepingDatabaseError } from '../errors'
|
||||
|
||||
const noopLog = { warn: vi.fn() } as unknown as import('@/lib/logger').Logger
|
||||
|
||||
describe('resolveSettlementAccount', () => {
|
||||
it('returns 1930 when the transaction has no cash_account_id', async () => {
|
||||
const { supabase } = createMockSupabase()
|
||||
|
||||
const result = await resolveSettlementAccount(supabase as never, 'company-1', null, noopLog)
|
||||
|
||||
expect(result).toBe('1930')
|
||||
expect(supabase.from).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns the linked cash account ledger_account', async () => {
|
||||
const { supabase, mockResult } = createMockSupabase()
|
||||
mockResult({ data: { ledger_account: '1940' }, error: null })
|
||||
|
||||
const result = await resolveSettlementAccount(supabase as never, 'company-1', 'ca-1', noopLog)
|
||||
|
||||
expect(result).toBe('1940')
|
||||
expect(supabase.from).toHaveBeenCalledWith('cash_accounts')
|
||||
})
|
||||
|
||||
it('throws a BookkeepingDatabaseError instead of silently falling back when the lookup errors', async () => {
|
||||
const { supabase, mockResult } = createMockSupabase()
|
||||
mockResult({ data: null, error: { message: 'boom' } })
|
||||
|
||||
await expect(
|
||||
resolveSettlementAccount(supabase as never, 'company-1', 'ca-1', noopLog),
|
||||
).rejects.toBeInstanceOf(BookkeepingDatabaseError)
|
||||
await expect(
|
||||
resolveSettlementAccount(supabase as never, 'company-1', 'ca-1', noopLog),
|
||||
).rejects.toMatchObject({
|
||||
operation: 'resolve_settlement_account',
|
||||
message: expect.stringContaining('boom'),
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to 1930 when cash_account_id does not match any row', async () => {
|
||||
const { supabase, mockResult } = createMockSupabase()
|
||||
mockResult({ data: null, error: null })
|
||||
|
||||
const result = await resolveSettlementAccount(supabase as never, 'company-1', 'ca-unknown', noopLog)
|
||||
|
||||
expect(result).toBe('1930')
|
||||
})
|
||||
|
||||
it('falls back to 1930 and warns when the row has no ledger_account', async () => {
|
||||
const { supabase, mockResult } = createMockSupabase()
|
||||
mockResult({ data: { ledger_account: null }, error: null })
|
||||
const warn = vi.fn()
|
||||
|
||||
const result = await resolveSettlementAccount(supabase as never, 'company-1', 'ca-1', {
|
||||
warn,
|
||||
} as unknown as import('@/lib/logger').Logger)
|
||||
|
||||
expect(result).toBe('1930')
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'settlement-account lookup returned no ledger_account; defaulting to 1930',
|
||||
expect.objectContaining({ cashAccountId: 'ca-1' }),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -296,6 +296,7 @@ export type BookkeepingOperation =
|
||||
| 'fetch_currency_receivables'
|
||||
| 'fetch_currency_payables'
|
||||
| 'check_existing_revaluation'
|
||||
| 'resolve_settlement_account'
|
||||
|
||||
export class BookkeepingDatabaseError extends Error {
|
||||
readonly code = BOOKKEEPING_DATABASE_ERROR
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
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.
|
||||
*/
|
||||
export async function resolveSettlementAccount(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
cashAccountId: string | null,
|
||||
log: Logger,
|
||||
): Promise<string> {
|
||||
if (!cashAccountId) 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
|
||||
}
|
||||
Reference in New Issue
Block a user