Per-account bank reconciliation + overdue/inbox/privacy fixes (#619)
* feat(reconciliation): scope bank reconciliation per cash account via transactions.cash_account_id A company with two same-currency cash accounts (e.g. checking 1930 + a savings account) saw every SEK transaction on every account, and the status card summed across both — reconciliation filtered transactions by CURRENCY while filtering GL lines by ACCOUNT (issue #604). Bind each bank transaction to the cash_accounts row it settled on: - New nullable transactions.cash_account_id FK (ON DELETE SET NULL — a bank transaction is räkenskapsinformation, BFL 7 kap, and must survive cash-account deletion) + a best-effort 4-pass backfill. - All reconciliation/transaction queries scope to the selected account with a NULL->currency fallback, so legacy/un-backfilled rows never disappear mid-backfill. - ingestTransactions stamps cash_account_id from the batch's settlementAccount; categorize + manualLink resolve and use it. - Bank leg now books to the transaction's actual settlement account via applySettlementAccount (no-op for 1930), so interest/fees on a savings/EUR account reconcile instead of mis-booking to 1930. - manualLink cross-checks the transaction's account and requires a voucher line on the selected account (no silent cross-account links). - BankReconciliationView: quick-book menu for any settlement account, in-flight request abort on account/date switch, 500-row truncation notice, per-account state reset. - pg-real coverage for the FK, all backfill passes, account-scoped query isolation, and cross-company isolation. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(supplier-invoices): stop marking paid invoices and credit notes as overdue update_overdue_supplier_invoices() (the daily pg_cron job) flipped every past-due 'registered'/'approved' row to 'overdue' without looking at the outstanding balance. Credit notes — created 'registered', remaining 0, due today — got flipped the next day, surfacing as "Förfallen" with "kvar att betala 0 kr"; so did any fully-paid invoice left in 'registered'/'approved'. Guard the cron on remaining_amount > 0.005 (the "fully paid" threshold used by the payment/match paths) and is_credit_note = false, and backfill the rows already mis-flagged (credit notes -> 'registered', paid -> 'paid' with paid_at stamped only when missing). pg-real coverage for the guarded function and the one-off backfill. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(invoice-inbox): refresh dokumentinkorg on realtime row changes The InvoiceInboxWorkspace only refetched on mount and on explicit in-component actions. When an inbox item was resolved out of band — the in-app agent sheet committing a staged create_supplier_invoice_from_inbox / book-direct op, the /pending page approving one, or another tab booking it — none of those paths called fetchItems(), so the booked underlag stayed in "Att göra" until a manual reload (issue #600). Add invoice_inbox_items to the supabase_realtime publication (mirrors the /pending fix in 20260520120100) and subscribe in the workspace, refetching the whole list on any change so derived status/counts/ordering stay authoritative. RLS scopes the channel to the user's company. fetchItems now preserves optimistic upload placeholders so a refetch firing mid-upload can't drop an in-flight row. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(privacy): disclose EU AI inference via Amazon Bedrock (eu-north-1) Update the privacy policy and DPA to state that AI inference, when AI features are enabled, runs inside the EU via Amazon Bedrock (eu-north-1, Stockholm) using Anthropic's Claude models — no transfer to a third country, prompts not retained after the call or used for model training. Add AWS as a subprocessor row and refresh the "last updated" dates. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(migrations): rename invoice_inbox_realtime to avoid version collision main's #617 shipped 20260605120000_transactions_original_description.sql — the same version this branch used for the inbox-realtime publication. The Supabase migration tracker keys on the numeric version, not the filename, so the preview branch failed with a duplicate-key error on supabase_migrations.schema_migrations (version 20260605120000 already exists). Rename to the unique version 20260605120500; the body (ALTER PUBLICATION) is order-independent. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(reconciliation): align run guard with status; harden filter interpolation Addresses PR review (greptile + compliance swarm): - The v1 and core bank/run routes rejected an unknown account uniformly, including the default '1930', while the status routes were lenient for '1930'. A company reconciling its primary SEK account without a cash_accounts row got 200 from status but 400 from run. Make run match status: '1930' falls back to currency-only scoping (cashAccountId undefined); non-default unknown accounts are still rejected. Adds a test. - /api/transactions accepts a user-supplied `currency` query param that was interpolated raw into a PostgREST .or() filter. Reject anything that isn't a 3-letter ISO code — RLS already scopes to the company, but an unsanitized value could otherwise malform/widen the filter. Assert currency/cashAccountId shape in scopeTransactionsToAccount as well. - categorize: log (instead of silently swallowing) a cash_accounts settlement-account lookup error, so a fall-back-to-1930 mis-booking is observable in the audit log. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(migrations): correct backfill UPDATE..FROM join; idempotent realtime publication Two SQL errors that only surface on real Postgres (CI pg-real + Supabase preview) — the unit suite mocks Supabase, so neither was caught locally. - Backfill pass (a): `UPDATE transactions t ... FROM journal_entry_lines jel JOIN cash_accounts ca ON ca.company_id = t.company_id` referenced the UPDATE target `t` inside the FROM join's ON clause, which Postgres rejects ("invalid reference to FROM-clause entry for table t"). Move the company match to WHERE; the JOIN now relates jel<->ca only. Semantics unchanged. - invoice_inbox_realtime: `ALTER PUBLICATION ... ADD TABLE` is not idempotent (SQLSTATE 42710 if the table is already a member). The earlier version-collision push partially applied it on the Supabase preview branch, so the re-apply errored. Guard with a pg_publication_tables existence check. Both statements validated against a real Postgres: the single-line tx binds, the two-bank-line transfer stays NULL, and the publication add runs twice cleanly. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(migrations): backfill pass (c) uses array_agg, not min(uuid) Postgres has no min() aggregate for uuid, so pass (c)'s min(id) raised "function min(uuid) does not exist" on apply (CI pg-real + Supabase). The HAVING count(*) = 1 already guarantees one row per group, so (array_agg(id))[1] returns that single id. Validated the full backfill (all four passes) and the overdue migration against a real Postgres: every pass binds / falls through as intended, and the overdue guard + backfill produce the right statuses. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(compliance): add RoPA entry for Amazon Bedrock AI inference (GDPR Art.30) The privacy policy now discloses AI inference (transaction categorization + document/receipt OCR) via Amazon Bedrock as a processing activity, but .compliance/ropa.yaml had no matching Art.30 record. Add it: opt-in consent basis, EU-region (eu-north-1) inference with no third-country transfer, prompts not retained or used for model training. Mirrors the privacy-page disclosure shipped in this PR. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
c6c86cded4
commit
953980c875
@@ -952,6 +952,13 @@ export const UpdateAccountSchema = z.object({
|
||||
export const BankLinkSchema = z.object({
|
||||
transaction_id: uuid,
|
||||
journal_entry_id: uuid,
|
||||
// Settlement account being reconciled. The voucher must have a line on this
|
||||
// account and the transaction must belong to it. Defaults to '1930' in the
|
||||
// route for back-compat.
|
||||
account_number: z
|
||||
.string()
|
||||
.regex(/^[0-9]{4}$/, 'Kontonummer måste vara 4 siffror')
|
||||
.optional(),
|
||||
})
|
||||
|
||||
export const BankUnlinkSchema = z.object({
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
stripBankNoise,
|
||||
type BookingTemplate,
|
||||
} from '../booking-templates'
|
||||
import { applySettlementAccount } from '../mapping-engine'
|
||||
|
||||
// ============================================================
|
||||
// Template Data Integrity
|
||||
@@ -801,3 +802,47 @@ describe('new and split templates', () => {
|
||||
expect(t!.deductibility_note_sv).toContain('46 kr/person')
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// applySettlementAccount — bank-leg routing for non-1930 accounts
|
||||
// ============================================================
|
||||
|
||||
describe('applySettlementAccount (bank-leg routing)', () => {
|
||||
it('routes the bank_interest_income debit leg to the transaction settlement account', () => {
|
||||
const template = getTemplateById('bank_interest_income')
|
||||
expect(template).toBeDefined()
|
||||
const tx = makeTransaction({ amount: 50, currency: 'SEK' })
|
||||
const base = buildMappingResultFromTemplate(template!, tx, 'enskild_firma')
|
||||
// Template hardcodes 1930 as the bank leg.
|
||||
expect(base.debit_account).toBe('1930')
|
||||
expect(base.credit_account).toBe('8310')
|
||||
|
||||
// Interest that landed on a savings account mapped to 1931 must debit 1931,
|
||||
// not 1930 — otherwise the real bank transaction never reconciles.
|
||||
const routed = applySettlementAccount(base, '1931')
|
||||
expect(routed.debit_account).toBe('1931')
|
||||
expect(routed.credit_account).toBe('8310')
|
||||
})
|
||||
|
||||
it('routes the bank_fees credit leg to the transaction settlement account', () => {
|
||||
const template = getTemplateById('bank_fees')
|
||||
expect(template).toBeDefined()
|
||||
const tx = makeTransaction({ amount: -29, currency: 'SEK' })
|
||||
const base = buildMappingResultFromTemplate(template!, tx, 'enskild_firma')
|
||||
expect(base.debit_account).toBe('6570')
|
||||
expect(base.credit_account).toBe('1930')
|
||||
|
||||
const routed = applySettlementAccount(base, '1931')
|
||||
expect(routed.debit_account).toBe('6570')
|
||||
expect(routed.credit_account).toBe('1931')
|
||||
})
|
||||
|
||||
it('is a no-op when the settlement account is 1930 (legacy/unresolved rows)', () => {
|
||||
const template = getTemplateById('bank_interest_income')!
|
||||
const tx = makeTransaction({ amount: 50, currency: 'SEK' })
|
||||
const base = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
|
||||
const routed = applySettlementAccount(base, '1930')
|
||||
expect(routed.debit_account).toBe('1930')
|
||||
expect(routed.credit_account).toBe('8310')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -374,7 +374,7 @@ function buildOwnAccountTransferResult(
|
||||
* This allows mapping rules and templates that don't explicitly set a bank account
|
||||
* to work correctly with secondary bank accounts (e.g. 1931).
|
||||
*/
|
||||
function applySettlementAccount(result: MappingResult, bankAccount: string): MappingResult {
|
||||
export function applySettlementAccount(result: MappingResult, bankAccount: string): MappingResult {
|
||||
if (bankAccount === '1930') return result
|
||||
return {
|
||||
...result,
|
||||
|
||||
@@ -440,39 +440,84 @@ describe('manualLink', () => {
|
||||
expect(result.error).toBe('Transaction is already linked to a journal entry')
|
||||
})
|
||||
|
||||
it('rejects when journal entry has no 1930 line', async () => {
|
||||
it('rejects when journal entry has no line on the selected account', async () => {
|
||||
const { supabase, enqueue } = createQueueMockSupabase()
|
||||
const tx = makeTransaction({ id: 'tx-1', journal_entry_id: null })
|
||||
|
||||
// Transaction found
|
||||
// Transaction found (cash_account_id null → cross-check skipped)
|
||||
enqueue({ data: tx })
|
||||
// Journal entry found
|
||||
enqueue({ data: { id: 'je-1', user_id: 'company-1', status: 'posted' } })
|
||||
// No 1930 lines
|
||||
// No line on the selected account
|
||||
enqueue({ data: [] })
|
||||
|
||||
const result = await manualLink(supabase as never, 'company-1', 'tx-1', 'je-1', 'user-1')
|
||||
const result = await manualLink(supabase as never, 'company-1', 'tx-1', 'je-1', 'user-1', '1930')
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe('Verifikationen saknar rad på bankkonto (19xx)')
|
||||
expect(result.error).toBe('Verifikationen saknar rad på 1930')
|
||||
})
|
||||
|
||||
it('succeeds when all validations pass', async () => {
|
||||
it('rejects when the transaction belongs to a different cash account', async () => {
|
||||
const { supabase, enqueue } = createQueueMockSupabase()
|
||||
const tx = makeTransaction({
|
||||
id: 'tx-1',
|
||||
journal_entry_id: null,
|
||||
cash_account_id: 'ca-1931',
|
||||
})
|
||||
|
||||
// Transaction found (bound to a cash account)
|
||||
enqueue({ data: tx })
|
||||
// Journal entry found + posted
|
||||
enqueue({ data: { id: 'je-1', user_id: 'company-1', status: 'posted' } })
|
||||
// Cross-check: this cash account maps to 1931, but we're reconciling 1930
|
||||
enqueue({ data: { ledger_account: '1931' } })
|
||||
|
||||
const result = await manualLink(supabase as never, 'company-1', 'tx-1', 'je-1', 'user-1', '1930')
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe('Transaktionen hör till 1931, inte 1930')
|
||||
})
|
||||
|
||||
it('succeeds when all validations pass (line on selected account)', async () => {
|
||||
const { supabase, enqueue } = createQueueMockSupabase()
|
||||
const tx = makeTransaction({ id: 'tx-1', journal_entry_id: null })
|
||||
|
||||
// Transaction found
|
||||
// Transaction found (cash_account_id null → cross-check skipped)
|
||||
enqueue({ data: tx })
|
||||
// Journal entry found
|
||||
enqueue({ data: { id: 'je-1', user_id: 'company-1', status: 'posted' } })
|
||||
// 1930 line exists
|
||||
enqueue({ data: [{ debit_amount: 1000, credit_amount: 0 }] })
|
||||
// Line exists on the selected account
|
||||
enqueue({ data: [{ debit_amount: 1000, credit_amount: 0, account_number: '1930' }] })
|
||||
// No existing link
|
||||
enqueue({ data: null, error: null })
|
||||
// Update succeeds
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
const result = await manualLink(supabase as never, 'company-1', 'tx-1', 'je-1', 'user-1')
|
||||
const result = await manualLink(supabase as never, 'company-1', 'tx-1', 'je-1', 'user-1', '1930')
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('succeeds for a bound transaction when the account matches', async () => {
|
||||
const { supabase, enqueue } = createQueueMockSupabase()
|
||||
const tx = makeTransaction({
|
||||
id: 'tx-1',
|
||||
journal_entry_id: null,
|
||||
cash_account_id: 'ca-1930',
|
||||
})
|
||||
|
||||
enqueue({ data: tx })
|
||||
enqueue({ data: { id: 'je-1', user_id: 'company-1', status: 'posted' } })
|
||||
// Cross-check: cash account maps to the account being reconciled
|
||||
enqueue({ data: { ledger_account: '1930' } })
|
||||
// Line exists on 1930
|
||||
enqueue({ data: [{ debit_amount: 1000, credit_amount: 0, account_number: '1930' }] })
|
||||
// No existing link
|
||||
enqueue({ data: null, error: null })
|
||||
// Update succeeds
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
const result = await manualLink(supabase as never, 'company-1', 'tx-1', 'je-1', 'user-1', '1930')
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
@@ -81,6 +81,45 @@ export interface ReconciliationOptions {
|
||||
* cash account so EUR transactions reconcile against 1932 etc.
|
||||
*/
|
||||
currency?: string
|
||||
/**
|
||||
* cash_accounts.id of the selected account. When set, transactions are
|
||||
* scoped to this exact account (with a currency fallback for legacy rows
|
||||
* whose cash_account_id hasn't been backfilled yet) instead of being matched
|
||||
* by currency alone — this is what stops two same-currency accounts (e.g.
|
||||
* checking 1930 + savings 1931) from pooling together. Omit for the legacy
|
||||
* currency-only behaviour.
|
||||
*/
|
||||
cashAccountId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope a transactions query builder to a single cash account, tolerating
|
||||
* legacy rows that predate the cash_account_id backfill:
|
||||
* cash_account_id = X OR (cash_account_id IS NULL AND currency = cur)
|
||||
* A bound row shows only on its own account; an unbound row falls back to
|
||||
* currency so nothing disappears mid-backfill. When cashAccountId is omitted
|
||||
* we keep the pure currency filter (back-compat).
|
||||
*/
|
||||
function scopeTransactionsToAccount<Q extends {
|
||||
or(filters: string): Q
|
||||
eq(column: string, value: string): Q
|
||||
}>(query: Q, cashAccountId: string | undefined, currency: string): Q {
|
||||
// Both values are interpolated into a raw PostgREST filter string below. They
|
||||
// are DB-derived in every caller (cash_accounts.id / .currency, or the 'SEK'
|
||||
// default), never raw user input — but assert their shape anyway so a future
|
||||
// caller cannot thread an unsanitized value through into the filter.
|
||||
if (!/^[A-Z]{3}$/.test(currency)) {
|
||||
throw new Error(`scopeTransactionsToAccount: invalid currency ${JSON.stringify(currency)}`)
|
||||
}
|
||||
if (cashAccountId) {
|
||||
if (!/^[0-9a-fA-F-]{36}$/.test(cashAccountId)) {
|
||||
throw new Error('scopeTransactionsToAccount: invalid cashAccountId (expected UUID)')
|
||||
}
|
||||
return query.or(
|
||||
`cash_account_id.eq.${cashAccountId},and(cash_account_id.is.null,currency.eq.${currency})`,
|
||||
)
|
||||
}
|
||||
return query.eq('currency', currency)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -175,19 +214,20 @@ export async function runReconciliation(
|
||||
dryRun = false,
|
||||
accountNumber = '1930',
|
||||
currency = 'SEK',
|
||||
cashAccountId,
|
||||
} = options
|
||||
|
||||
// Fetch unlinked GL lines via RPC
|
||||
const glLines = await fetchUnlinkedGLLines(supabase, companyId, accountNumber, dateFrom, dateTo)
|
||||
|
||||
// Fetch unmatched transactions
|
||||
// Fetch unmatched transactions, scoped to the selected cash account.
|
||||
let query = supabase
|
||||
.from('transactions')
|
||||
.select('*')
|
||||
.eq('company_id', companyId)
|
||||
.is('journal_entry_id', null)
|
||||
.eq('is_ignored', false)
|
||||
.eq('currency', currency)
|
||||
query = scopeTransactionsToAccount(query, cashAccountId, currency)
|
||||
|
||||
if (dateFrom) query = query.gte('date', dateFrom)
|
||||
if (dateTo) query = query.lte('date', dateTo)
|
||||
@@ -268,16 +308,19 @@ export async function getReconciliationStatus(
|
||||
dateTo?: string,
|
||||
bankAccount = '1930',
|
||||
currency: string = 'SEK',
|
||||
cashAccountId?: string,
|
||||
): Promise<ReconciliationStatus> {
|
||||
// Get all transactions in range. Ignored rows are pulled too so the totals
|
||||
// card still reflects what the bank actually moved, but they're excluded
|
||||
// from the "unmatched" count below — the user has explicitly said they
|
||||
// don't want them surfacing as something to reconcile.
|
||||
// Get all transactions in range, scoped to the selected cash account. Ignored
|
||||
// rows are pulled too so the totals card still reflects what the bank
|
||||
// actually moved, but they're excluded from the "unmatched" count below — the
|
||||
// user has explicitly said they don't want them surfacing as something to
|
||||
// reconcile. Scoping by cash account (not just currency) is what stops a
|
||||
// second same-currency account from inflating bankTotal here.
|
||||
let txQuery = supabase
|
||||
.from('transactions')
|
||||
.select('amount, journal_entry_id, reconciliation_method, is_ignored')
|
||||
.eq('company_id', companyId)
|
||||
.eq('currency', currency)
|
||||
txQuery = scopeTransactionsToAccount(txQuery, cashAccountId, currency)
|
||||
|
||||
if (dateFrom) txQuery = txQuery.gte('date', dateFrom)
|
||||
if (dateTo) txQuery = txQuery.lte('date', dateTo)
|
||||
@@ -397,7 +440,8 @@ export async function manualLink(
|
||||
companyId: string,
|
||||
transactionId: string,
|
||||
journalEntryId: string,
|
||||
userId: string
|
||||
userId: string,
|
||||
accountNumber: string = '1930',
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
// Fetch transaction
|
||||
const { data: tx, error: txError } = await supabase
|
||||
@@ -431,16 +475,36 @@ export async function manualLink(
|
||||
return { success: false, error: 'Journal entry is not posted' }
|
||||
}
|
||||
|
||||
// Check for a bank account line (19xx class accounts)
|
||||
// Defense-in-depth: the transaction must belong to the account being
|
||||
// reconciled. A transaction bound to 1930 must not be linked against a 1931
|
||||
// voucher even if the caller passes accountNumber=1931. Legacy rows with no
|
||||
// cash_account_id fall through (the UI list already gates them by currency).
|
||||
if (tx.cash_account_id) {
|
||||
const { data: txCa } = await supabase
|
||||
.from('cash_accounts')
|
||||
.select('ledger_account')
|
||||
.eq('id', tx.cash_account_id)
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
if (txCa?.ledger_account && txCa.ledger_account !== accountNumber) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Transaktionen hör till ${txCa.ledger_account}, inte ${accountNumber}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for a bank account line on the SELECTED settlement account. The old
|
||||
// "any 19xx line" check let a 1930 transaction link to a voucher that only
|
||||
// touched 1931 — a cross-account link that silently hides a real imbalance.
|
||||
const { data: lines } = await supabase
|
||||
.from('journal_entry_lines')
|
||||
.select('debit_amount, credit_amount, account_number')
|
||||
.eq('journal_entry_id', journalEntryId)
|
||||
.gte('account_number', '1900')
|
||||
.lte('account_number', '1999')
|
||||
.eq('account_number', accountNumber)
|
||||
|
||||
if (!lines || lines.length === 0) {
|
||||
return { success: false, error: 'Verifikationen saknar rad på bankkonto (19xx)' }
|
||||
return { success: false, error: `Verifikationen saknar rad på ${accountNumber}` }
|
||||
}
|
||||
|
||||
// Check that no other transaction is already linked to this entry
|
||||
|
||||
@@ -39,6 +39,9 @@ vi.mock('@/lib/currency/riksbanken', () => ({
|
||||
|
||||
function createQueueMockSupabase() {
|
||||
const resultQueue: { data: unknown; error: unknown }[] = []
|
||||
// Captures .insert() payloads keyed by table, so tests can assert what was
|
||||
// written (e.g. cash_account_id stamping).
|
||||
const inserts: Record<string, unknown[]> = {}
|
||||
|
||||
/**
|
||||
* Push one or more results onto the queue.
|
||||
@@ -50,25 +53,31 @@ function createQueueMockSupabase() {
|
||||
}
|
||||
}
|
||||
|
||||
const buildChain = (): unknown => {
|
||||
const buildChain = (table: string): unknown => {
|
||||
const handler: ProxyHandler<object> = {
|
||||
get(_target, prop) {
|
||||
if (prop === 'then') {
|
||||
const next = resultQueue.shift() ?? { data: null, error: null }
|
||||
return (resolve: (v: unknown) => void) => resolve(next)
|
||||
}
|
||||
return (..._args: unknown[]) => buildChain()
|
||||
if (prop === 'insert') {
|
||||
return (payload: unknown) => {
|
||||
;(inserts[table] ??= []).push(payload)
|
||||
return buildChain(table)
|
||||
}
|
||||
}
|
||||
return (..._args: unknown[]) => buildChain(table)
|
||||
},
|
||||
}
|
||||
return new Proxy({}, handler)
|
||||
}
|
||||
|
||||
const supabase = {
|
||||
from: vi.fn().mockImplementation(() => buildChain()),
|
||||
rpc: vi.fn().mockImplementation(() => buildChain()),
|
||||
from: vi.fn().mockImplementation((table: string) => buildChain(table)),
|
||||
rpc: vi.fn().mockImplementation(() => buildChain('rpc')),
|
||||
}
|
||||
|
||||
return { supabase, enqueue }
|
||||
return { supabase, enqueue, inserts }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -154,6 +163,54 @@ describe('ingestTransactions', () => {
|
||||
expect(result.transaction_ids).toEqual(['tx-1'])
|
||||
})
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 1c. Stamps cash_account_id from the settlement account
|
||||
// -----------------------------------------------------------------------
|
||||
it('stamps cash_account_id on the insert when settlementAccount resolves', async () => {
|
||||
const { supabase, enqueue, inserts } = createQueueMockSupabase()
|
||||
const raw = makeRaw({ amount: -100 })
|
||||
const inserted = makeTransaction({ id: 'tx-1', external_id: raw.external_id })
|
||||
|
||||
enqueue({ data: [], error: null }) // booked map
|
||||
enqueue({ data: [], error: null }) // unbooked map
|
||||
enqueue({ data: [], error: null }) // supplier invoices
|
||||
enqueue({ data: [], error: null }) // external_id dedup
|
||||
enqueue({ data: { id: 'ca-1931' }, error: null }) // cash_accounts lookup
|
||||
enqueue({ data: inserted, error: null }) // insert
|
||||
mockEvaluateMappingRules.mockResolvedValue(makeMappingResult({ confidence: 0.5 }))
|
||||
|
||||
const result = await ingestTransactions(supabase as never, COMPANY_ID, USER_ID, [raw], {
|
||||
settlementAccount: '1931',
|
||||
})
|
||||
|
||||
expect(result.imported).toBe(1)
|
||||
expect(supabase.from).toHaveBeenCalledWith('cash_accounts')
|
||||
const txInserts = inserts['transactions'] ?? []
|
||||
expect(txInserts).toHaveLength(1)
|
||||
expect((txInserts[0] as { cash_account_id?: string | null }).cash_account_id).toBe('ca-1931')
|
||||
})
|
||||
|
||||
it('inserts cash_account_id null when no settlementAccount is given', async () => {
|
||||
const { supabase, enqueue, inserts } = createQueueMockSupabase()
|
||||
const raw = makeRaw({ amount: -100 })
|
||||
const inserted = makeTransaction({ id: 'tx-1', external_id: raw.external_id })
|
||||
|
||||
enqueue({ data: [], error: null }) // booked map
|
||||
enqueue({ data: [], error: null }) // unbooked map
|
||||
enqueue({ data: [], error: null }) // supplier invoices
|
||||
enqueue({ data: [], error: null }) // external_id dedup
|
||||
// No cash_accounts lookup — settlementAccount omitted.
|
||||
enqueue({ data: inserted, error: null }) // insert
|
||||
mockEvaluateMappingRules.mockResolvedValue(makeMappingResult({ confidence: 0.5 }))
|
||||
|
||||
const result = await ingestTransactions(supabase as never, COMPANY_ID, USER_ID, [raw])
|
||||
|
||||
expect(result.imported).toBe(1)
|
||||
expect(supabase.from).not.toHaveBeenCalledWith('cash_accounts')
|
||||
const txInserts = inserts['transactions'] ?? []
|
||||
expect((txInserts[0] as { cash_account_id?: string | null }).cash_account_id).toBeNull()
|
||||
})
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 2. Detects duplicates
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@@ -213,6 +213,25 @@ export async function ingestTransactions(
|
||||
data?.forEach(r => existingExternalIds.add(r.external_id))
|
||||
}
|
||||
|
||||
// Resolve the cash account this batch settled on, once. Every row in one
|
||||
// ingest call shares a settlement account: enable-banking calls this per
|
||||
// account (settlementAccount = account.ledger_account), CSV import passes the
|
||||
// single account the user picked. cash_accounts.ledger_account is unique per
|
||||
// company, so this is a single-row lookup. Tolerate a miss — the row stays
|
||||
// unbound (cash_account_id NULL) and reconciliation falls back to currency.
|
||||
// We never auto-create a cash account here; that would race upsertFromPsd2's
|
||||
// seed-promotion logic in lib/cash-accounts/service.ts.
|
||||
let cashAccountId: string | null = null
|
||||
if (options?.settlementAccount) {
|
||||
const { data: ca } = await supabase
|
||||
.from('cash_accounts')
|
||||
.select('id')
|
||||
.eq('company_id', companyId)
|
||||
.eq('ledger_account', options.settlementAccount)
|
||||
.maybeSingle()
|
||||
cashAccountId = (ca?.id as string | undefined) ?? null
|
||||
}
|
||||
|
||||
// Track already-matched invoice IDs within this ingestion batch
|
||||
// to prevent suggesting the same invoice for multiple transactions
|
||||
const matchedInvoiceIds = new Set<string>()
|
||||
@@ -270,6 +289,7 @@ export async function ingestTransactions(
|
||||
company_id: companyId,
|
||||
user_id: userId,
|
||||
bank_connection_id: raw.bank_connection_id || null,
|
||||
cash_account_id: cashAccountId,
|
||||
external_id: raw.external_id,
|
||||
date: raw.date,
|
||||
description: description,
|
||||
|
||||
Reference in New Issue
Block a user