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
+9 -2
View File
@@ -24,9 +24,16 @@ export async function POST(request: Request) {
const validation = await validateBody(request, BankLinkSchema)
if (!validation.success) return validation.response
const { transaction_id, journal_entry_id } = validation.data
const { transaction_id, journal_entry_id, account_number } = validation.data
const result = await manualLink(supabase, companyId, transaction_id, journal_entry_id, user.id)
const result = await manualLink(
supabase,
companyId,
transaction_id,
journal_entry_id,
user.id,
account_number ?? '1930',
)
if (!result.success) {
return NextResponse.json({ error: result.error }, { status: 400 })
+10 -7
View File
@@ -28,30 +28,33 @@ export async function POST(request: Request) {
const accountNumber = account_number ?? '1930'
// Defense-in-depth: only allow account numbers the company has registered as
// a cash account. Applies uniformly including '1930' — the cash_accounts
// backfill seeds 1930 for every company that had a SEK PSD2 account, and the
// AccountPickerDialog seeds it for new companies on first connection.
// Defense-in-depth: reject a non-default account the company hasn't
// registered as a cash account. The default '1930' is exempt — when no
// cash_accounts row exists it falls back to currency-only scoping
// (cashAccountId undefined), so a company reconciling its primary SEK account
// without a row behaves exactly as before this feature. Matches the status
// endpoint, which is likewise lenient for '1930'.
const { data: cashAccount } = await supabase
.from('cash_accounts')
.select('currency')
.select('id, currency')
.eq('company_id', companyId)
.eq('ledger_account', accountNumber)
.maybeSingle()
if (!cashAccount) {
if (!cashAccount && accountNumber !== '1930') {
return NextResponse.json(
{ error: 'Okänt kassakonto för det här företaget' },
{ status: 400 },
)
}
const currency = (cashAccount.currency as string | undefined) ?? 'SEK'
const currency = (cashAccount?.currency as string | undefined) ?? 'SEK'
const result = await runReconciliation(supabase, companyId, user.id, {
dateFrom: date_from,
dateTo: date_to,
accountNumber,
currency,
cashAccountId: cashAccount?.id as string | undefined,
dryRun: dry_run ?? false,
})
+3 -1
View File
@@ -23,7 +23,7 @@ export async function GET(request: Request) {
// produces nonsense.
const { data: cashAccount } = await supabase
.from('cash_accounts')
.select('currency')
.select('id, currency')
.eq('company_id', companyId)
.eq('ledger_account', accountNumber)
.maybeSingle()
@@ -36,6 +36,7 @@ export async function GET(request: Request) {
}
const currency = (cashAccount?.currency as string | undefined) ?? 'SEK'
const cashAccountId = cashAccount?.id as string | undefined
const status = await getReconciliationStatus(
supabase,
@@ -44,6 +45,7 @@ export async function GET(request: Request) {
dateTo,
accountNumber,
currency,
cashAccountId,
)
return NextResponse.json({ data: status })
@@ -41,6 +41,19 @@ vi.mock('@/lib/bookkeeping/transaction-entries', () => ({
const mockSaveUserMappingRule = vi.fn()
vi.mock('@/lib/bookkeeping/mapping-engine', () => ({
saveUserMappingRule: (...args: unknown[]) => mockSaveUserMappingRule(...args),
// Mirror the real implementation: rewrite a 1930 bank leg to the settlement
// account, no-op when the settlement account is 1930.
applySettlementAccount: (
result: { debit_account?: string; credit_account?: string },
bankAccount: string,
) =>
bankAccount === '1930'
? result
: {
...result,
debit_account: result.debit_account === '1930' ? bankAccount : result.debit_account,
credit_account: result.credit_account === '1930' ? bankAccount : result.credit_account,
},
}))
vi.mock('@/lib/bookkeeping/counterparty-templates', () => ({
+29 -1
View File
@@ -5,7 +5,7 @@ import { ensureInitialized } from '@/lib/init'
import { buildMappingResultFromCategory } from '@/lib/bookkeeping/category-mapping'
import { getTemplateById, buildMappingResultFromTemplate, validateTemplateForEntity } from '@/lib/bookkeeping/booking-templates'
import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries'
import { saveUserMappingRule } from '@/lib/bookkeeping/mapping-engine'
import { saveUserMappingRule, applySettlementAccount } from '@/lib/bookkeeping/mapping-engine'
import { upsertCounterpartyTemplate, buildMappingResultFromCounterpartyTemplate } from '@/lib/bookkeeping/counterparty-templates'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
@@ -226,6 +226,34 @@ export const POST = withRouteContext(
)
}
// Book the bank leg against the transaction's ACTUAL settlement account
// rather than the hardcoded 1930 in the templates. Without this, interest
// or fees that landed on a savings/EUR account mis-book to 1930 and the
// real bank line never reconciles. applySettlementAccount only rewrites a
// 1930 leg and is a no-op when the settlement account is 1930 — so legacy
// rows with no cash_account_id behave exactly as before.
let settlementAccount = '1930'
if (transaction.cash_account_id) {
const { data: txCashAccount, error: cashAccountError } = await supabase
.from('cash_accounts')
.select('ledger_account')
.eq('id', transaction.cash_account_id)
.eq('company_id', companyId)
.maybeSingle()
if (cashAccountError) {
// Don't fail the booking — fall back to 1930 — but surface the lookup
// failure so a silent mis-booking to the wrong bank leg stays auditable.
txLog.warn('settlement-account lookup failed; defaulting to 1930', {
cashAccountId: transaction.cash_account_id,
error: cashAccountError.message,
})
}
if (txCashAccount?.ledger_account) {
settlementAccount = txCashAccount.ledger_account as string
}
}
mappingResult = applySettlementAccount(mappingResult, settlementAccount)
txLog.info('mapping resolved', {
debit: mappingResult.debit_account,
credit: mappingResult.credit_account,
+30 -9
View File
@@ -18,28 +18,39 @@ export async function GET(request: Request) {
const unmatched = searchParams.get('unmatched') === 'true'
const reconciled = searchParams.get('reconciled') === 'true'
const currency = searchParams.get('currency') || undefined
// currency is interpolated into the PostgREST .or() filter below, so reject
// anything that isn't a 3-letter ISO code. RLS still scopes results to the
// company, but an unsanitized value could otherwise malform or widen the
// filter (PostgREST filter injection).
if (currency && !/^[A-Z]{3}$/.test(currency)) {
return NextResponse.json({ error: 'Ogiltig valutakod' }, { status: 400 })
}
const dateFrom = searchParams.get('date_from') || undefined
const dateTo = searchParams.get('date_to') || undefined
// When set, return only ignored rows — used by the reconciliation view to
// surface a "Visa ignorerade" undo list. The default (no param) behaviour
// continues to exclude ignored rows from unmatched results.
const onlyIgnored = searchParams.get('only_ignored') === 'true'
// account_number is accepted for API symmetry with the reconciliation status
// endpoint; transactions don't carry a cash_account FK today (PSD2 account
// identity is embedded in external_id), so we use it to derive a default
// currency when the caller didn't supply one. Anything more precise needs
// the cash_account_id backfill tracked as Tier 4.
// account_number selects which cash account to scope to. We resolve it to a
// cash_accounts.id (ledger_account is unique per company) and scope
// transactions by that id, falling back to currency for legacy rows whose
// cash_account_id hasn't been backfilled yet. This is what stops two
// same-currency accounts from showing each other's transactions.
const accountNumberParam = searchParams.get('account_number') || undefined
let derivedCurrency = currency
if (!derivedCurrency && accountNumberParam) {
let cashAccountId: string | undefined
if (accountNumberParam) {
const { data: cashAccount } = await supabase
.from('cash_accounts')
.select('currency')
.select('id, currency')
.eq('company_id', companyId)
.eq('ledger_account', accountNumberParam)
.maybeSingle()
if (cashAccount?.currency) derivedCurrency = cashAccount.currency as string
if (cashAccount) {
cashAccountId = cashAccount.id as string
if (!derivedCurrency && cashAccount.currency) derivedCurrency = cashAccount.currency as string
}
}
let query = supabase
@@ -60,7 +71,17 @@ export async function GET(request: Request) {
if (onlyIgnored) query = query.eq('is_ignored', true)
if (derivedCurrency) query = query.eq('currency', derivedCurrency)
// Scope to the selected cash account. With a resolved id, match that account
// OR legacy NULL rows of the same currency (so nothing disappears mid-
// backfill). With only a currency (no account), filter by currency. With
// neither (e.g. the company-wide only_ignored recovery list), no scope.
if (cashAccountId) {
query = query.or(
`cash_account_id.eq.${cashAccountId},and(cash_account_id.is.null,currency.eq.${derivedCurrency ?? 'SEK'})`,
)
} else if (derivedCurrency) {
query = query.eq('currency', derivedCurrency)
}
if (dateFrom) query = query.gte('date', dateFrom)
if (dateTo) query = query.lte('date', dateTo)
@@ -129,6 +129,7 @@ describe('POST /reconciliation/bank/run', () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
cash_accounts: { data: { id: 'ca-1930', currency: 'SEK' }, error: null },
}),
)
const res = await runPOST(
@@ -146,7 +147,50 @@ describe('POST /reconciliation/bank/run', () => {
expect.anything(),
COMPANY_ID,
'user-1',
expect.objectContaining({ dryRun: false }),
expect.objectContaining({ dryRun: false, accountNumber: '1930', cashAccountId: 'ca-1930' }),
)
})
it('rejects an unknown settlement account', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
// No matching cash_accounts row for the requested account_number.
cash_accounts: { data: null, error: null },
}),
)
const res = await runPOST(
postRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/reconciliation/bank/run`, {
account_number: '9999',
}),
{ params: Promise.resolve({ companyId: COMPANY_ID }) },
)
expect(res.status).toBe(400)
expect(runRecMock).not.toHaveBeenCalled()
})
it('runs the default 1930 account even without a cash_accounts row (currency fallback)', async () => {
// Mirrors the status endpoint's leniency: the primary SEK account always
// reconciles via the currency fallback, so a company without a 1930
// cash_accounts row is not blocked from running reconciliation.
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
cash_accounts: { data: null, error: null },
}),
)
const res = await runPOST(
postRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/reconciliation/bank/run`, {
account_number: '1930',
}),
{ params: Promise.resolve({ companyId: COMPANY_ID }) },
)
expect(res.status).toBe(200)
expect(runRecMock).toHaveBeenCalledWith(
expect.anything(),
COMPANY_ID,
'user-1',
expect.objectContaining({ accountNumber: '1930', cashAccountId: undefined }),
)
})
@@ -154,6 +198,7 @@ describe('POST /reconciliation/bank/run', () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
cash_accounts: { data: { id: 'ca-1930', currency: 'SEK' }, error: null },
}),
)
const res = await runPOST(
@@ -207,6 +252,7 @@ describe('GET /reconciliation/bank/status', () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
cash_accounts: { data: { id: 'ca-1930', currency: 'SEK' }, error: null },
}),
)
const res = await statusGET(
@@ -223,6 +269,7 @@ describe('GET /reconciliation/bank/status', () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
cash_accounts: { data: { id: 'ca-1930', currency: 'SEK' }, error: null },
}),
)
const res = await statusGET(
@@ -21,6 +21,10 @@ const RunRequest = z
.object({
date_from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
date_to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
// Settlement account (BAS code) to reconcile against, e.g. '1930' (SEK) or
// '1932' (EUR). Defaults to '1930'. Required for multi-account companies to
// reconcile anything other than their primary SEK account.
account_number: z.string().regex(/^\d{4}$/).optional(),
})
// Bound the window so a key with no explicit range can't trigger an
// unbounded join across years. 366 days covers a full räkenskapsår + a
@@ -67,6 +71,7 @@ registerEndpoint({
'Creating new journal entries — this only links bank transactions to existing GL lines. Matching to invoices — use `:match-invoice` or `:match-supplier-invoice` for explicit invoice payments.',
pitfalls: [
'date_from / date_to default to the company\'s full bank history if omitted. Specify a window for predictable performance.',
'account_number defaults to 1930. Multi-account companies must pass the BAS code of the account they are reconciling (e.g. 1932 for a EUR account), or it silently reconciles 1930.',
'Idempotency-Key is mandatory.',
'matches.confidence is between 0 and 1; the matcher only applies matches above the internal threshold (currently ~0.85).',
],
@@ -110,11 +115,35 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
}
const body = parsed.data
// Resolve the settlement account to its cash account (currency + id) so the
// matcher scopes transactions to this exact account, not every same-currency
// account. The default '1930' is exempt from the existence check — it falls
// back to currency-only scoping, matching the status endpoint and the
// pre-feature behaviour. A non-default unknown account is rejected.
const accountNumber = body.account_number ?? '1930'
const { data: cashAccount } = await ctx.supabase
.from('cash_accounts')
.select('id, currency')
.eq('company_id', ctx.companyId!)
.eq('ledger_account', accountNumber)
.maybeSingle()
if (!cashAccount && accountNumber !== '1930') {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
issues: [{ field: 'account_number', message: 'Okänt kassakonto för det här företaget' }],
},
})
}
let result
try {
result = await runReconciliation(ctx.supabase, ctx.companyId!, ctx.userId, {
dateFrom: body.date_from,
dateTo: body.date_to,
accountNumber,
currency: (cashAccount?.currency as string | undefined) ?? 'SEK',
cashAccountId: cashAccount?.id as string | undefined,
dryRun: ctx.dryRun,
})
} catch (err) {
@@ -66,10 +66,13 @@ export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>(
const Filters = z.object({
date_from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
date_to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
// Settlement account (BAS code), e.g. '1930' / '1932'. Defaults to 1930.
account_number: z.string().regex(/^\d{4}$/).optional(),
})
const parsed = Filters.safeParse({
date_from: url.searchParams.get('date_from') ?? undefined,
date_to: url.searchParams.get('date_to') ?? undefined,
account_number: url.searchParams.get('account_number') ?? undefined,
})
if (!parsed.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
@@ -83,12 +86,31 @@ export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>(
})
}
const accountNumber = parsed.data.account_number ?? '1930'
const { data: cashAccount } = await ctx.supabase
.from('cash_accounts')
.select('id, currency')
.eq('company_id', ctx.companyId!)
.eq('ledger_account', accountNumber)
.maybeSingle()
if (!cashAccount && accountNumber !== '1930') {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
issues: [{ field: 'account_number', message: 'Okänt kassakonto för det här företaget' }],
},
})
}
try {
const status = await getReconciliationStatus(
ctx.supabase,
ctx.companyId!,
parsed.data.date_from,
parsed.data.date_to,
accountNumber,
(cashAccount?.currency as string | undefined) ?? 'SEK',
cashAccount?.id as string | undefined,
)
return ok(status, { requestId: ctx.requestId })
} catch (err) {