fix(transactions): bind manually-fed transactions to a cash account (#1016) (#1017)

* fix(transactions): bind manually-fed transactions to a cash account (#1016)

create_transactions inserted rows with cash_account_id = null, so ledger
accounts fed via MCP/CSV without a PSD2 feed (e.g. 1935 Wise SEK) had no
kassakonto: get_reconciliation_status 404'd with "Okänt kassakonto" and the
"Matcha mot befintlig verifikation" dialog fell back to 1930.

No schema change: cash_accounts.bank_connection_id is already nullable and
source='manual' already exists (every company is seeded a manual 1930). This
is the creation-side leg of the #985-#987 root cause: the resolution chain was
fixed, but manually-fed accounts never got the cash_account_id link.

- Add ensureManualCashAccount (lib/cash-accounts/service.ts): find-or-create a
  manual (source='manual', bank_connection_id=null) cash_accounts row for a
  ledger slot, tolerating the (company_id, ledger_account) UNIQUE race.
- Add an optional ledger_account hint (^19xx) to gnubok_create_transactions;
  commitCreateTransaction resolves/creates the manual account and sets
  cash_account_id on the inserted row.

Reconciliation and voucher matching then resolve the real account unchanged.
Forward-looking; historical cash_account_id=null remediation stays in #1001.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Alexander Reinthal <email@reinthal.me>

* fix(cash-accounts): guard ensureManualCashAccount against currency mismatch (CodeRabbit #1017)

The existing-row lookup matched only on (company_id, ledger_account) and
returned the row id ignoring currency, so a SEK transaction hinting at a ledger
already claimed for USD would bind to the wrong-currency cash account. Since
that pair is UNIQUE (one currency per ledger), a mismatch is a real conflict:
throw instead of silently mis-binding.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Alexander Reinthal <email@reinthal.me>

---------

Signed-off-by: Alexander Reinthal <email@reinthal.me>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Jakob Wennberg <jakob.wennberg@gmail.com>
This commit is contained in:
Alexander Reinthal
2026-07-16 16:15:06 +02:00
committed by GitHub
co-authored by Claude Opus 4.8 Jakob Wennberg
parent aa5edd3aa7
commit f8033cb32d
6 changed files with 262 additions and 0 deletions
+1
View File
@@ -167,3 +167,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-07-16] Two bugs from one customer report (an AB). Bug 1 (acct 2893 showed 2393's "langfristig del" memo after an andringsverifikation): root cause = CorrectionEntryDialog never re-derived line_description on account change (JournalEntryForm does). Fixed forward via a pure helper (correction-line-description.ts) that refreshes the memo only when it is empty or still equals the prev account's name (preserves hand-typed memos). Chose NO prod data repair: the wrong memo sits on a POSTED verifikat (immutable per migration-017 trigger); it is cosmetic (account number + amounts correct, all reports key off the number); ~26 posted lines across 11 cos share this stale-echo pattern, all fix-forward only. Deferred the twin entry-level header fix (#1031). Bug 2 (auto tax-deadlines never appeared): root cause = generation only fired on a settings save where a TAX field CHANGED value (didTaxFieldsChange); settings are filled once at onboarding so re-saving generated nothing -> only 5/776 real cos had system deadlines. Chose count-based self-heal (regenerate when the company has 0 system deadlines) over always-regenerate, because generateTaxDeadlinesForUser deletes+reinserts and would reset is_completed/status on every unrelated save. Also wired the /deadlines empty-state to the existing (dead) /api/tax-deadlines/generate route, and fixed a 1000-row PostgREST cap in the annual cron. Backfilled 771 real cos with zero system deadlines via scripts/backfill-tax-deadlines.ts. Deferred moms_period=yearly config (#1030, 295 filers, largest VAT cohort): helarsmoms deadline (SFL 26 kap. 33-33b) depends on EU-trade status (no flag in CompanySettingsForDeadlines) and, for AB, the income-tax-return date.
[2026-07-15] Repaired the single legacy paid credit note blocking invoices_credit_note_not_paid validation by normalizing its invoice metadata to sent, clearing payment fields, setting zero payable remainder, and linking its existing balanced posted V44 reversal: the immutable voucher already exactly reversed V42 and was not edited or duplicated.
[2026-07-14] Issue #1016 (create_transactions never binds cash_account_id): fixed forward-only via an optional ledger_account hint on gnubok_create_transactions + commitCreateTransaction, resolved through a new ensureManualCashAccount find-or-create helper (lib/cash-accounts/service.ts). No migration: cash_accounts.bank_connection_id is already nullable and source='manual' already exists (the seeded 1930 is a manual row), so the handoff's premise that manual kassakonton need a schema change was wrong. Pre-creating a manual row does NOT race upsertFromPsd2 (the ingest.ts "never auto-create" worry): a later PSD2 connection promotes a manual holder in place, the intended flow (#916/#56). Hint restricted to ^19\d{2}$ (BAS kassa/bank group) so a transaction can't bind to a non-cash account. Scoped to the MCP create path per user decision; POST /api/cash-accounts + settings UI, relaxing ingest.ts's settlement_account auto-create, and historical backfill of cash_account_id=null rows (deferred to #1001) are follow-ups.
+8
View File
@@ -2831,6 +2831,7 @@ export const tools: McpTool[] = [
amount: { type: 'number', description: 'Positive = income, negative = expense.' },
description: { type: 'string', description: 'Free-text description shown in /transactions.' },
currency: { type: 'string', description: 'ISO 4217 code. Default SEK.' },
ledger_account: { type: 'string', description: 'Optional BAS 19xx cash account (e.g. "1935") this row settles on. Binds it to a manual kassakonto so reconciliation and voucher matching resolve the right account instead of falling back to 1930.' },
bank_connection_id: { type: 'string', description: 'Optional UUID of a bank_connections row to associate with.' },
external_id: { type: 'string', description: 'Optional external reference (e.g., Airtable record ID). Shown in the preview; the DB enforces uniqueness per user, so the second commit of the same external_id will fail at approval.' },
},
@@ -2862,6 +2863,7 @@ export const tools: McpTool[] = [
const amount = Number(item.amount)
const description = ((item.description as string) ?? '').trim()
const currency = ((item.currency as string) || 'SEK').toUpperCase()
const ledgerAccount = (item.ledger_account as string) || null
const bankConnectionId = (item.bank_connection_id as string) || null
const externalId = (item.external_id as string) || null
@@ -2874,12 +2876,18 @@ export const tools: McpTool[] = [
if (!description) {
throw new Error(`transactions[${i}].description is required.`)
}
// Restrict the hint to BAS group 19 (kassa/bank): binding a transaction
// to a non-cash account would misroute reconciliation and matching.
if (ledgerAccount && !/^19\d{2}$/.test(ledgerAccount)) {
throw new Error(`transactions[${i}].ledger_account must be a BAS 19xx cash account (e.g. "1935").`)
}
const params = {
date,
amount,
description,
currency,
ledger_account: ledgerAccount,
bank_connection_id: bankConnectionId,
external_id: externalId,
}
+108
View File
@@ -16,6 +16,7 @@ import {
defaultLedgerForCurrency,
getRevokedConnectionIds,
upsertFromPsd2,
ensureManualCashAccount,
} from '../service'
type CashRow = { ledger_account: string; bank_connection_id: string | null }
@@ -556,3 +557,110 @@ describe('upsertFromPsd2', () => {
).rejects.toThrow(/duplicate key/)
})
})
// ── ensureManualCashAccount ──────────────────────────────────────────────
interface ManualStub {
lookup: { data: { id: string; currency?: string } | null; error?: { message: string } | null }
insert?: { data: { id: string } | null; error?: { message: string; code?: string } | null }
reread?: { data: { id: string } | null; error?: { message: string } | null }
inserted: Array<Record<string, unknown>>
lookupCount: number
}
function makeManualSupabase(stub: ManualStub): SupabaseClient {
return {
from: vi.fn((table: string) => {
expect(table).toBe('cash_accounts')
return {
// lookup / reread path: select().eq().eq().maybeSingle()
select: vi.fn(() => ({
eq: vi.fn(() => ({
eq: vi.fn(() => ({
maybeSingle: vi.fn(() => {
stub.lookupCount += 1
// First maybeSingle = initial lookup; a second = post-23505 reread.
const r = stub.lookupCount === 1 ? stub.lookup : stub.reread ?? { data: null }
return Promise.resolve({ data: r.data, error: r.error ?? null })
}),
})),
})),
})),
// insert().select('id').single()
insert: vi.fn((payload: Record<string, unknown>) => {
stub.inserted.push(payload)
return {
select: vi.fn(() => ({
single: vi.fn(() =>
Promise.resolve({
data: stub.insert?.data ?? null,
error: stub.insert?.error ?? null,
}),
),
})),
}
}),
}
}),
} as unknown as SupabaseClient
}
describe('ensureManualCashAccount', () => {
it('returns the existing row id without inserting when the currency matches', async () => {
const stub: ManualStub = { lookup: { data: { id: 'ca-1', currency: 'SEK' } }, inserted: [], lookupCount: 0 }
const id = await ensureManualCashAccount(makeManualSupabase(stub), 'c1', '1935', 'sek')
expect(id).toBe('ca-1')
expect(stub.inserted).toHaveLength(0)
})
it('throws when the existing row is a different currency (UNIQUE ledger conflict)', async () => {
const stub: ManualStub = { lookup: { data: { id: 'ca-usd', currency: 'USD' } }, inserted: [], lookupCount: 0 }
await expect(
ensureManualCashAccount(makeManualSupabase(stub), 'c1', '1935', 'SEK'),
).rejects.toThrow(/denominated in USD, not SEK/)
expect(stub.inserted).toHaveLength(0)
})
it('creates a manual row (source=manual, uppercased currency) when none exists', async () => {
const stub: ManualStub = {
lookup: { data: null },
insert: { data: { id: 'ca-new' } },
inserted: [],
lookupCount: 0,
}
const id = await ensureManualCashAccount(makeManualSupabase(stub), 'c1', '1935', 'sek')
expect(id).toBe('ca-new')
expect(stub.inserted[0]).toMatchObject({
company_id: 'c1',
ledger_account: '1935',
currency: 'SEK',
source: 'manual',
is_primary: false,
enabled: true,
})
})
it('re-reads the winner on a 23505 race instead of throwing', async () => {
const stub: ManualStub = {
lookup: { data: null },
insert: { data: null, error: { message: 'duplicate key', code: '23505' } },
reread: { data: { id: 'ca-winner' } },
inserted: [],
lookupCount: 0,
}
const id = await ensureManualCashAccount(makeManualSupabase(stub), 'c1', '1935', 'SEK')
expect(id).toBe('ca-winner')
})
it('throws on a non-race insert failure', async () => {
const stub: ManualStub = {
lookup: { data: null },
insert: { data: null, error: { message: 'boom' } },
inserted: [],
lookupCount: 0,
}
await expect(
ensureManualCashAccount(makeManualSupabase(stub), 'c1', '1935', 'SEK'),
).rejects.toThrow(/boom/)
})
})
+83
View File
@@ -486,6 +486,89 @@ export async function upsertFromPsd2(
}
}
/**
* Find (or create) a manual cash account for a BAS ledger slot, so transactions
* ingested outside the PSD2 flow (create_transactions / CSV) can carry a real
* cash_account_id instead of NULL. Without the link, reconciliation 404s on the
* account and the match dialog falls back to 1930 (issue #1016).
*
* Manual rows (source='manual', bank_connection_id=null) are already first-class:
* every company is seeded a manual 1930 the same way, and upsertFromPsd2 promotes
* a manual holder in place if a bank later claims the slot. So pre-creating one
* here does NOT race the PSD2 sync (the concern noted in lib/transactions/ingest.ts):
* the worst case is a later connection promoting this row, which is the intended flow.
*
* Keyed on the (company_id, ledger_account) UNIQUE constraint: a concurrent
* insert surfaces as 23505, which we treat as "someone else won the race" and
* re-read. The row's currency follows the first transaction that created it; a
* ledger account holds one currency by that same constraint.
*/
export async function ensureManualCashAccount(
supabase: SupabaseClient,
companyId: string,
ledgerAccount: string,
currency: string,
name?: string | null,
): Promise<string> {
const existing = await supabase
.from('cash_accounts')
.select('id, currency')
.eq('company_id', companyId)
.eq('ledger_account', ledgerAccount)
.maybeSingle()
if (existing.error) {
throw new Error(`ensureManualCashAccount lookup failed: ${existing.error.message}`)
}
if (existing.data) {
const row = existing.data as { id: string; currency: string | null }
// (company_id, ledger_account) is UNIQUE, so a ledger holds exactly one
// currency. A different-currency transaction pointing at the same ledger is
// a real conflict (e.g. a SEK row landing on a ledger already claimed for
// USD): fail loudly instead of binding it to the wrong-currency account.
if (row.currency && row.currency.toUpperCase() !== currency.toUpperCase()) {
throw new Error(
`Cash account ${ledgerAccount} is denominated in ${row.currency}, not ${currency.toUpperCase()}`,
)
}
return row.id
}
const insert = await supabase
.from('cash_accounts')
.insert({
company_id: companyId,
ledger_account: ledgerAccount,
currency: currency.toUpperCase(),
name: name?.trim() || `Bankkonto ${currency.toUpperCase()}`,
enabled: true,
is_primary: false,
source: 'manual' as CashAccountSource,
})
.select('id')
.single()
if (insert.error) {
// Lost the (company_id, ledger_account) race: re-read the winner's row.
if (insert.error.code === '23505') {
const reread = await supabase
.from('cash_accounts')
.select('id')
.eq('company_id', companyId)
.eq('ledger_account', ledgerAccount)
.maybeSingle()
if (reread.data) return (reread.data as { id: string }).id
}
log.error('ensureManualCashAccount insert failed', {
companyId,
ledgerAccount,
error: insert.error.message,
})
throw new Error(`ensureManualCashAccount insert failed: ${insert.error.message}`)
}
return (insert.data as { id: string }).id
}
/**
* Toggle a cash account's enabled flag. Used by the AccountPicker when a user
* opts in or out of syncing a particular PSD2 account.
@@ -304,6 +304,53 @@ describe('commitPendingOperation: create_transaction', () => {
expect(result.http_status).toBe(409)
expect(result.error).toMatch(/already exists/)
})
it('binds cash_account_id when ledger_account is given, creating the manual account', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: null, error: null }) // ensureManualCashAccount lookup miss
enqueue({ data: { id: 'ca-1935' }, error: null }) // ensureManualCashAccount insert
enqueue({ data: { id: 'tx-9' }, error: null }) // executor transactions insert
enqueue({ data: null, error: null }) // dispatcher's update
const op = makePendingOp({
operation_type: 'create_transaction',
params: {
date: '2026-05-01',
amount: -200,
description: 'WISE KORT ST',
currency: 'SEK',
ledger_account: '1935',
},
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('committed')
expect(result.data).toMatchObject({ transaction_id: 'tx-9' })
})
it('rejects a non-19xx ledger_account with 400', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: null, error: null }) // dispatcher's reject update
const op = makePendingOp({
operation_type: 'create_transaction',
params: {
date: '2026-05-01',
amount: -200,
description: 'WISE KORT ST',
ledger_account: '3001',
},
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('failed')
expect(result.http_status).toBe(400)
expect(result.error).toMatch(/19xx cash account/)
})
})
// ─── import_sie ─────────────────────────────────────────────────────
+15
View File
@@ -26,6 +26,7 @@ import {
createCreditNoteJournalEntry,
} from '@/lib/bookkeeping/invoice-entries'
import { resolveSettlementAccount } from '@/lib/bookkeeping/settlement-account'
import { ensureManualCashAccount } from '@/lib/cash-accounts/service'
import { createJournalEntry, findFiscalPeriod, reverseEntry, validateBalance } from '@/lib/bookkeeping/engine'
import { coerceDimensionsBag } from '@/lib/bookkeeping/dimension-resolver'
import { cancelOrphanedPaymentEntry } from '@/lib/bookkeeping/cancel-orphaned-entry'
@@ -648,12 +649,25 @@ async function commitCreateTransaction(
const amount = Number(params.amount)
const description = (params.description as string) ?? ''
const currency = ((params.currency as string) || 'SEK') as Currency
const ledgerAccount = (params.ledger_account as string) || null
const bankConnectionId = (params.bank_connection_id as string) || null
const externalId = (params.external_id as string) || null
if (!date || !description.trim() || !Number.isFinite(amount)) {
return { error: 'date, description, and amount are required', status: 400 }
}
if (ledgerAccount && !/^19\d{2}$/.test(ledgerAccount)) {
return { error: 'ledger_account must be a BAS 19xx cash account', status: 400 }
}
// Bind the row to a manual kassakonto when a ledger account is given, so
// reconciliation and voucher matching resolve the real account instead of
// falling back to 1930 (issue #1016). Find-or-create; the row's currency
// follows this transaction.
let cashAccountId: string | null = null
if (ledgerAccount) {
cashAccountId = await ensureManualCashAccount(supabase, companyId, ledgerAccount, currency)
}
const { data, error } = await supabase
.from('transactions')
@@ -661,6 +675,7 @@ async function commitCreateTransaction(
user_id: userId,
company_id: companyId,
bank_connection_id: bankConnectionId,
cash_account_id: cashAccountId,
external_id: externalId,
date,
description: description.trim(),