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:
Jakob Wennberg
2026-06-01 18:26:13 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent c6c86cded4
commit 953980c875
30 changed files with 1453 additions and 89 deletions
@@ -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)
})
+76 -12
View File
@@ -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