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
@@ -172,6 +172,7 @@ export function makeTransaction(overrides: Partial<Transaction> = {}): Transacti
|
||||
user_id: 'user-1',
|
||||
company_id: 'company-1',
|
||||
bank_connection_id: null,
|
||||
cash_account_id: null,
|
||||
external_id: null,
|
||||
date: '2024-06-15',
|
||||
description: 'ICA MAXI STOCKHOLM',
|
||||
|
||||
@@ -89,6 +89,78 @@ export async function seedCompany(overrides: { isClosed?: boolean } = {}): Promi
|
||||
return { userId, companyId, fiscalPeriodId }
|
||||
}
|
||||
|
||||
// Insert a cash account (cash_accounts row). ledger_account is unique per
|
||||
// company; is_primary defaults false to avoid the one-primary partial index.
|
||||
export async function insertCashAccount(params: {
|
||||
companyId: string
|
||||
ledgerAccount: string
|
||||
currency?: string
|
||||
iban?: string | null
|
||||
externalUid?: string | null
|
||||
isPrimary?: boolean
|
||||
enabled?: boolean
|
||||
source?: 'enable_banking' | 'manual' | 'sie_import'
|
||||
bankConnectionId?: string | null
|
||||
}): Promise<string> {
|
||||
const id = randomUUID()
|
||||
await getPool().query(
|
||||
`INSERT INTO public.cash_accounts
|
||||
(id, company_id, ledger_account, currency, iban, external_uid,
|
||||
is_primary, enabled, source, bank_connection_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
|
||||
[
|
||||
id,
|
||||
params.companyId,
|
||||
params.ledgerAccount,
|
||||
params.currency ?? 'SEK',
|
||||
params.iban ?? null,
|
||||
params.externalUid ?? null,
|
||||
params.isPrimary ?? false,
|
||||
params.enabled ?? true,
|
||||
params.source ?? 'manual',
|
||||
params.bankConnectionId ?? null,
|
||||
],
|
||||
)
|
||||
return id
|
||||
}
|
||||
|
||||
// Insert a bank transaction row. cashAccountId/journalEntryId default null so
|
||||
// tests can exercise the backfill and the NULL-fallback scoping.
|
||||
export async function insertTransaction(params: {
|
||||
companyId: string
|
||||
userId: string
|
||||
currency?: string
|
||||
amount?: number
|
||||
date?: string
|
||||
description?: string
|
||||
externalId?: string | null
|
||||
journalEntryId?: string | null
|
||||
cashAccountId?: string | null
|
||||
isIgnored?: boolean
|
||||
}): Promise<string> {
|
||||
const id = randomUUID()
|
||||
await getPool().query(
|
||||
`INSERT INTO public.transactions
|
||||
(id, company_id, user_id, currency, amount, date, description,
|
||||
external_id, journal_entry_id, cash_account_id, is_ignored, category)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, 'uncategorized')`,
|
||||
[
|
||||
id,
|
||||
params.companyId,
|
||||
params.userId,
|
||||
params.currency ?? 'SEK',
|
||||
params.amount ?? -100,
|
||||
params.date ?? '2026-06-01',
|
||||
params.description ?? 'Test tx',
|
||||
params.externalId ?? null,
|
||||
params.journalEntryId ?? null,
|
||||
params.cashAccountId ?? null,
|
||||
params.isIgnored ?? false,
|
||||
],
|
||||
)
|
||||
return id
|
||||
}
|
||||
|
||||
// Insert a draft journal entry and return its id. Uses a placeholder
|
||||
// voucher_number=0 which commit_journal_entry() will overwrite on commit.
|
||||
export async function insertDraftJournalEntry(params: {
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { seedCompany } from '@/tests/pg/fixtures'
|
||||
import { getPool } from '@/tests/pg/setup'
|
||||
|
||||
/**
|
||||
* pg-real coverage for update_overdue_supplier_invoices() and its fix in
|
||||
* 20260607120000_supplier_invoice_overdue_skip_paid_and_credit_notes.sql.
|
||||
*
|
||||
* Regression: supplier invoices (and credit notes) with remaining_amount = 0
|
||||
* were being flipped to 'overdue' by the daily cron — surfacing in the UI as
|
||||
* "Förfallen" with "kvar att betala 0 kr". Credit notes are the systematic
|
||||
* case: they are created status='registered', remaining_amount=0,
|
||||
* due_date=today, so the cron caught them the next day.
|
||||
*
|
||||
* Locks in:
|
||||
* - The function still marks a genuinely-unpaid, past-due invoice overdue.
|
||||
* - It NEVER marks a credit note overdue.
|
||||
* - It NEVER marks a fully-paid (remaining ~= 0) invoice overdue.
|
||||
* - Not-yet-due invoices are untouched.
|
||||
* - The one-off backfill corrects rows already mis-flagged.
|
||||
*
|
||||
* Tests write through the superuser pool (RLS bypassed); the function is
|
||||
* SECURITY DEFINER. Dates are pinned far in the past/future so the result is
|
||||
* independent of the wall-clock date the suite runs on.
|
||||
*/
|
||||
|
||||
const PAST = '2000-01-01'
|
||||
const FUTURE = '2999-01-01'
|
||||
|
||||
const MIGRATION_SQL = readFileSync(
|
||||
join(
|
||||
process.cwd(),
|
||||
'supabase/migrations/20260607120000_supplier_invoice_overdue_skip_paid_and_credit_notes.sql',
|
||||
),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
async function insertSupplier(userId: string, companyId: string): Promise<string> {
|
||||
const id = randomUUID()
|
||||
await getPool().query(
|
||||
`INSERT INTO public.suppliers
|
||||
(id, user_id, company_id, name, supplier_type, country, default_payment_terms, default_currency)
|
||||
VALUES ($1, $2, $3, 'Leverantör AB', 'swedish_business', 'SE', 30, 'SEK')`,
|
||||
[id, userId, companyId],
|
||||
)
|
||||
return id
|
||||
}
|
||||
|
||||
async function insertSupplierInvoice(params: {
|
||||
userId: string
|
||||
companyId: string
|
||||
supplierId: string
|
||||
status: string
|
||||
dueDate: string
|
||||
total: number
|
||||
remaining: number
|
||||
paidAmount?: number
|
||||
isCreditNote?: boolean
|
||||
paidAt?: string | null
|
||||
}): Promise<string> {
|
||||
const id = randomUUID()
|
||||
const arrivalNumber = (Date.now() % 1_000_000_000) + Math.floor(Math.random() * 100_000)
|
||||
await getPool().query(
|
||||
`INSERT INTO public.supplier_invoices
|
||||
(id, user_id, company_id, supplier_id, arrival_number, supplier_invoice_number,
|
||||
invoice_date, due_date, received_date, status, currency,
|
||||
subtotal, vat_amount, total, paid_amount, remaining_amount, paid_at,
|
||||
vat_treatment, reverse_charge, is_credit_note)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $7, $7, $8, 'SEK',
|
||||
$9, 0, $9, $10, $11, $12, 'standard_25', false, $13)`,
|
||||
[
|
||||
id,
|
||||
params.userId,
|
||||
params.companyId,
|
||||
params.supplierId,
|
||||
arrivalNumber,
|
||||
`LF-${arrivalNumber}`,
|
||||
params.dueDate,
|
||||
params.status,
|
||||
params.total,
|
||||
params.paidAmount ?? 0,
|
||||
params.remaining,
|
||||
params.paidAt ?? null,
|
||||
params.isCreditNote ?? false,
|
||||
],
|
||||
)
|
||||
return id
|
||||
}
|
||||
|
||||
async function statusOf(id: string): Promise<string> {
|
||||
const { rows } = await getPool().query(
|
||||
'SELECT status FROM public.supplier_invoices WHERE id = $1',
|
||||
[id],
|
||||
)
|
||||
return rows[0].status
|
||||
}
|
||||
|
||||
describe('update_overdue_supplier_invoices()', () => {
|
||||
it('marks a genuinely-unpaid, past-due invoice overdue', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
const supplierId = await insertSupplier(userId, companyId)
|
||||
const id = await insertSupplierInvoice({
|
||||
userId, companyId, supplierId,
|
||||
status: 'approved', dueDate: PAST, total: 1000, remaining: 1000,
|
||||
})
|
||||
|
||||
await getPool().query('SELECT public.update_overdue_supplier_invoices()')
|
||||
|
||||
expect(await statusOf(id)).toBe('overdue')
|
||||
})
|
||||
|
||||
it('never marks a credit note overdue (remaining 0, status registered)', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
const supplierId = await insertSupplier(userId, companyId)
|
||||
// Mirrors how the credit routes create a credit note: registered, fully
|
||||
// settled (remaining 0), due today (here: long past).
|
||||
const id = await insertSupplierInvoice({
|
||||
userId, companyId, supplierId,
|
||||
status: 'registered', dueDate: PAST, total: 1000, remaining: 0,
|
||||
isCreditNote: true,
|
||||
})
|
||||
|
||||
await getPool().query('SELECT public.update_overdue_supplier_invoices()')
|
||||
|
||||
expect(await statusOf(id)).toBe('registered')
|
||||
})
|
||||
|
||||
it('never marks a fully-paid (remaining ~0) invoice overdue', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
const supplierId = await insertSupplier(userId, companyId)
|
||||
const id = await insertSupplierInvoice({
|
||||
userId, companyId, supplierId,
|
||||
status: 'approved', dueDate: PAST, total: 1000, remaining: 0, paidAmount: 1000,
|
||||
})
|
||||
|
||||
await getPool().query('SELECT public.update_overdue_supplier_invoices()')
|
||||
|
||||
expect(await statusOf(id)).toBe('approved')
|
||||
})
|
||||
|
||||
it('leaves not-yet-due invoices untouched', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
const supplierId = await insertSupplier(userId, companyId)
|
||||
const id = await insertSupplierInvoice({
|
||||
userId, companyId, supplierId,
|
||||
status: 'approved', dueDate: FUTURE, total: 1000, remaining: 1000,
|
||||
})
|
||||
|
||||
await getPool().query('SELECT public.update_overdue_supplier_invoices()')
|
||||
|
||||
expect(await statusOf(id)).toBe('approved')
|
||||
})
|
||||
})
|
||||
|
||||
describe('overdue backfill (migration 20260607120000)', () => {
|
||||
it('reverts a credit note wrongly stuck on overdue back to registered', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
const supplierId = await insertSupplier(userId, companyId)
|
||||
const id = await insertSupplierInvoice({
|
||||
userId, companyId, supplierId,
|
||||
status: 'overdue', dueDate: PAST, total: 1000, remaining: 0, isCreditNote: true,
|
||||
})
|
||||
|
||||
// Idempotent: re-running the migration only touches status='overdue' rows.
|
||||
await getPool().query(MIGRATION_SQL)
|
||||
|
||||
expect(await statusOf(id)).toBe('registered')
|
||||
})
|
||||
|
||||
it('marks a fully-paid invoice stuck on overdue as paid (and stamps paid_at)', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
const supplierId = await insertSupplier(userId, companyId)
|
||||
const id = await insertSupplierInvoice({
|
||||
userId, companyId, supplierId,
|
||||
status: 'overdue', dueDate: PAST, total: 1000, remaining: 0, paidAmount: 1000,
|
||||
paidAt: null,
|
||||
})
|
||||
|
||||
await getPool().query(MIGRATION_SQL)
|
||||
|
||||
const { rows } = await getPool().query(
|
||||
'SELECT status, paid_at FROM public.supplier_invoices WHERE id = $1',
|
||||
[id],
|
||||
)
|
||||
expect(rows[0].status).toBe('paid')
|
||||
expect(rows[0].paid_at).not.toBeNull()
|
||||
})
|
||||
|
||||
it('leaves a genuinely-overdue unpaid invoice on overdue', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
const supplierId = await insertSupplier(userId, companyId)
|
||||
const id = await insertSupplierInvoice({
|
||||
userId, companyId, supplierId,
|
||||
status: 'overdue', dueDate: PAST, total: 1000, remaining: 1000,
|
||||
})
|
||||
|
||||
await getPool().query(MIGRATION_SQL)
|
||||
|
||||
expect(await statusOf(id)).toBe('overdue')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,253 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
seedCompany,
|
||||
insertCashAccount,
|
||||
insertTransaction,
|
||||
insertDraftJournalEntry,
|
||||
} from '@/tests/pg/fixtures'
|
||||
import { getPool } from '@/tests/pg/setup'
|
||||
|
||||
/**
|
||||
* pg-real coverage for transactions.cash_account_id
|
||||
* (20260606120000_transactions_cash_account_id.sql + the paired backfill
|
||||
* 20260606120100_..._backfill.sql).
|
||||
*
|
||||
* Locks in:
|
||||
* - FK ON DELETE SET NULL — deleting a cash account never deletes the bank
|
||||
* transaction (räkenskapsinformation, BFL 7 kap), only nulls the link.
|
||||
* - The four backfill passes (booked single 19xx line, PSD2 external_id,
|
||||
* single-account-of-currency, leave NULL) and their guards.
|
||||
* - The headline isolation: a query scoped to one account (with the
|
||||
* NULL→currency fallback) never returns another same-currency account's
|
||||
* rows. This is the regression test for "shows 1930 even when you switch /
|
||||
* sums all transactions".
|
||||
* - Cross-company isolation of the backfill.
|
||||
*/
|
||||
|
||||
// Run the real backfill migration SQL (idempotent: only touches NULL rows) so
|
||||
// the test exercises exactly what ships, not a re-implementation.
|
||||
const BACKFILL_SQL = readFileSync(
|
||||
join(
|
||||
process.cwd(),
|
||||
'supabase/migrations/20260606120100_transactions_cash_account_id_backfill.sql',
|
||||
),
|
||||
'utf8',
|
||||
)
|
||||
async function runBackfill(): Promise<void> {
|
||||
await getPool().query(BACKFILL_SQL)
|
||||
}
|
||||
|
||||
// Insert a journal entry (draft) with the given bank-class line account
|
||||
// numbers. One line per account at amount 100 (debit). Balance isn't required
|
||||
// for a draft entry — the balance trigger only fires on draft→posted.
|
||||
async function insertEntryWithBankLines(params: {
|
||||
userId: string
|
||||
companyId: string
|
||||
fiscalPeriodId: string
|
||||
bankAccounts: string[]
|
||||
}): Promise<string> {
|
||||
const jeId = await insertDraftJournalEntry({
|
||||
userId: params.userId,
|
||||
companyId: params.companyId,
|
||||
fiscalPeriodId: params.fiscalPeriodId,
|
||||
})
|
||||
for (const acct of params.bankAccounts) {
|
||||
await getPool().query(
|
||||
`INSERT INTO public.journal_entry_lines
|
||||
(journal_entry_id, account_number, debit_amount, credit_amount)
|
||||
VALUES ($1, $2, 100, 0)`,
|
||||
[jeId, acct],
|
||||
)
|
||||
}
|
||||
return jeId
|
||||
}
|
||||
|
||||
async function getCashAccountId(txId: string): Promise<string | null> {
|
||||
const { rows } = await getPool().query(
|
||||
`SELECT cash_account_id FROM public.transactions WHERE id = $1`,
|
||||
[txId],
|
||||
)
|
||||
return rows[0]?.cash_account_id ?? null
|
||||
}
|
||||
|
||||
describe('transactions.cash_account_id — schema + FK', () => {
|
||||
it('ON DELETE SET NULL keeps the transaction when its cash account is deleted', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
const caId = await insertCashAccount({ companyId, ledgerAccount: '1930' })
|
||||
const txId = await insertTransaction({ companyId, userId, cashAccountId: caId })
|
||||
|
||||
await getPool().query(`DELETE FROM public.cash_accounts WHERE id = $1`, [caId])
|
||||
|
||||
const { rows } = await getPool().query(
|
||||
`SELECT id, cash_account_id FROM public.transactions WHERE id = $1`,
|
||||
[txId],
|
||||
)
|
||||
expect(rows).toHaveLength(1) // transaction survived
|
||||
expect(rows[0].cash_account_id).toBeNull() // link was nulled, not cascaded
|
||||
})
|
||||
})
|
||||
|
||||
describe('transactions.cash_account_id — backfill pass (a) booked rows', () => {
|
||||
it('binds a booked transaction via its single bank line; skips multi-bank-line vouchers', async () => {
|
||||
const { userId, companyId, fiscalPeriodId } = await seedCompany()
|
||||
// Two SEK accounts so the single-account fallback (pass c) cannot fire.
|
||||
await insertCashAccount({ companyId, ledgerAccount: '1930' })
|
||||
const ca1931 = await insertCashAccount({ companyId, ledgerAccount: '1931' })
|
||||
|
||||
// Single 1931 line → should bind to the 1931 cash account.
|
||||
const jeSingle = await insertEntryWithBankLines({
|
||||
userId,
|
||||
companyId,
|
||||
fiscalPeriodId,
|
||||
bankAccounts: ['1931'],
|
||||
})
|
||||
const txSingle = await insertTransaction({
|
||||
companyId,
|
||||
userId,
|
||||
journalEntryId: jeSingle,
|
||||
})
|
||||
|
||||
// Two bank lines (1930 + 1931) → ambiguous transfer, must stay NULL.
|
||||
const jeTransfer = await insertEntryWithBankLines({
|
||||
userId,
|
||||
companyId,
|
||||
fiscalPeriodId,
|
||||
bankAccounts: ['1930', '1931'],
|
||||
})
|
||||
const txTransfer = await insertTransaction({
|
||||
companyId,
|
||||
userId,
|
||||
journalEntryId: jeTransfer,
|
||||
})
|
||||
|
||||
await runBackfill()
|
||||
|
||||
expect(await getCashAccountId(txSingle)).toBe(ca1931)
|
||||
expect(await getCashAccountId(txTransfer)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('transactions.cash_account_id — backfill pass (b) PSD2 external_id', () => {
|
||||
it('routes by IBAN and by external_uid embedded in external_id', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
// Two SEK accounts → pass (c) cannot fire, so only the PSD2 identity binds.
|
||||
const caIban = await insertCashAccount({
|
||||
companyId,
|
||||
ledgerAccount: '1930',
|
||||
iban: 'SE4550000000058398257466',
|
||||
})
|
||||
const caUid = await insertCashAccount({
|
||||
companyId,
|
||||
ledgerAccount: '1931',
|
||||
externalUid: 'psd2-uid-b',
|
||||
})
|
||||
|
||||
const txIban = await insertTransaction({
|
||||
companyId,
|
||||
userId,
|
||||
externalId: 'eb_SE4550000000058398257466_tx1',
|
||||
})
|
||||
const txUid = await insertTransaction({
|
||||
companyId,
|
||||
userId,
|
||||
externalId: 'eb_psd2-uid-b_tx2',
|
||||
})
|
||||
const txUnknown = await insertTransaction({
|
||||
companyId,
|
||||
userId,
|
||||
externalId: 'eb_nomatch_tx3',
|
||||
})
|
||||
|
||||
await runBackfill()
|
||||
|
||||
expect(await getCashAccountId(txIban)).toBe(caIban)
|
||||
expect(await getCashAccountId(txUid)).toBe(caUid)
|
||||
expect(await getCashAccountId(txUnknown)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('transactions.cash_account_id — backfill pass (c) single-account-of-currency', () => {
|
||||
it('binds when the company has exactly one enabled account of the currency', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
const ca = await insertCashAccount({ companyId, ledgerAccount: '1930', currency: 'SEK' })
|
||||
// CSV-style row: no external_id, unbooked.
|
||||
const tx = await insertTransaction({ companyId, userId, currency: 'SEK' })
|
||||
|
||||
await runBackfill()
|
||||
|
||||
expect(await getCashAccountId(tx)).toBe(ca)
|
||||
})
|
||||
|
||||
it('leaves NULL when the company has two same-currency accounts', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
await insertCashAccount({ companyId, ledgerAccount: '1930', currency: 'SEK' })
|
||||
await insertCashAccount({ companyId, ledgerAccount: '1931', currency: 'SEK' })
|
||||
const tx = await insertTransaction({ companyId, userId, currency: 'SEK' })
|
||||
|
||||
await runBackfill()
|
||||
|
||||
expect(await getCashAccountId(tx)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('transactions.cash_account_id — account-scoped query isolation', () => {
|
||||
it('scopes to one account with a NULL→currency fallback, never leaking same-currency rows', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
const ca1930 = await insertCashAccount({ companyId, ledgerAccount: '1930', currency: 'SEK' })
|
||||
const ca1931 = await insertCashAccount({ companyId, ledgerAccount: '1931', currency: 'SEK' })
|
||||
|
||||
const tx1930 = await insertTransaction({ companyId, userId, currency: 'SEK', cashAccountId: ca1930 })
|
||||
const tx1931 = await insertTransaction({ companyId, userId, currency: 'SEK', cashAccountId: ca1931 })
|
||||
const txNullSek = await insertTransaction({ companyId, userId, currency: 'SEK' })
|
||||
const txNullEur = await insertTransaction({ companyId, userId, currency: 'EUR' })
|
||||
|
||||
// Mirror the runtime predicate:
|
||||
// cash_account_id = X OR (cash_account_id IS NULL AND currency = cur)
|
||||
const scoped = async (cashAccountId: string, currency: string): Promise<string[]> => {
|
||||
const { rows } = await getPool().query(
|
||||
`SELECT id FROM public.transactions
|
||||
WHERE company_id = $1
|
||||
AND (cash_account_id = $2 OR (cash_account_id IS NULL AND currency = $3))`,
|
||||
[companyId, cashAccountId, currency],
|
||||
)
|
||||
return rows.map((r) => r.id)
|
||||
}
|
||||
|
||||
const for1930 = await scoped(ca1930, 'SEK')
|
||||
expect(for1930).toContain(tx1930)
|
||||
expect(for1930).toContain(txNullSek) // legacy NULL row visible via fallback
|
||||
expect(for1930).not.toContain(tx1931) // the other account never leaks
|
||||
expect(for1930).not.toContain(txNullEur) // wrong-currency NULL excluded
|
||||
|
||||
const for1931 = await scoped(ca1931, 'SEK')
|
||||
expect(for1931).toContain(tx1931)
|
||||
expect(for1931).toContain(txNullSek)
|
||||
expect(for1931).not.toContain(tx1930)
|
||||
})
|
||||
})
|
||||
|
||||
describe('transactions.cash_account_id — cross-company isolation', () => {
|
||||
it('backfill never binds a transaction to another company\'s cash account', async () => {
|
||||
const a = await seedCompany()
|
||||
const b = await seedCompany()
|
||||
|
||||
const caA = await insertCashAccount({ companyId: a.companyId, ledgerAccount: '1930' })
|
||||
const caB = await insertCashAccount({ companyId: b.companyId, ledgerAccount: '1930' })
|
||||
|
||||
const jeA = await insertEntryWithBankLines({
|
||||
userId: a.userId,
|
||||
companyId: a.companyId,
|
||||
fiscalPeriodId: a.fiscalPeriodId,
|
||||
bankAccounts: ['1930'],
|
||||
})
|
||||
const txA = await insertTransaction({ companyId: a.companyId, userId: a.userId, journalEntryId: jeA })
|
||||
|
||||
await runBackfill()
|
||||
|
||||
const boundA = await getCashAccountId(txA)
|
||||
expect(boundA).toBe(caA)
|
||||
expect(boundA).not.toBe(caB)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user