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
@@ -27,6 +27,7 @@ import {
} from 'lucide-react'
import Link from 'next/link'
import { cn, formatCurrency } from '@/lib/utils'
import { createClient } from '@/lib/supabase/client'
import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
import type { InvoiceExtractionResult } from '@/types'
import BookDirectlyDialog from '@/components/extensions/general/BookDirectlyDialog'
@@ -217,7 +218,19 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
try {
const res = await fetch('/api/extensions/ext/invoice-inbox/items?limit=500')
const json = await res.json()
if (res.ok) setItems(json.data?.items ?? [])
if (res.ok) {
const serverItems: InboxItem[] = json.data?.items ?? []
// Preserve optimistic upload placeholders that haven't resolved to a
// server row yet. A refetch can now fire mid-upload (a realtime event
// from an unrelated booking), and a wholesale replace would briefly
// drop the in-flight placeholder. Placeholders carry a `temp-` id that
// never collides with a real row, and uploadFile() removes its own
// placeholder before its fetchItems(), so this never duplicates.
setItems((prev) => {
const pending = prev.filter((it) => it.isPlaceholder)
return pending.length > 0 ? [...pending, ...serverItems] : serverItems
})
}
} catch (err) {
console.error('[invoice-inbox] fetchItems failed:', err)
} finally {
@@ -252,6 +265,33 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
.catch(() => { /* keep 'accrual' default */ })
}, [fetchItems, fetchInboxAddress])
// Realtime: refetch when any invoice_inbox_items row changes for this
// company. The inbox is routinely resolved "out of band" — the in-app agent
// sheet commits a staged create_supplier_invoice_from_inbox / book-direct
// operation, the /pending page approves one, or another tab books it — and
// none of those paths call this component's fetchItems(). Without this, a
// booked underlag stayed in "Att göra" until a manual reload (issue #600).
// RLS scopes the channel to the user's company, so we never receive other
// tenants' events; we refetch the whole list (rather than patch in place) so
// the derived status, count pills, and ordering stay authoritative. Mirrors
// the /pending page subscription (app/(dashboard)/pending/page.tsx).
useEffect(() => {
const supabase = createClient()
const channel = supabase
.channel('invoice_inbox_items:list')
.on(
'postgres_changes',
{ event: '*', schema: 'public', table: 'invoice_inbox_items' },
() => {
fetchItems()
}
)
.subscribe()
return () => {
void supabase.removeChannel(channel)
}
}, [fetchItems])
// Read the onboarding-dismissed flag from localStorage after mount
// (SSR-safe — no window access during initial render).
useEffect(() => {
+116 -34
View File
@@ -15,6 +15,8 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
@@ -37,6 +39,27 @@ const METHOD_LABELS: Record<string, string> = {
manual: 'Manuell',
}
// One-click bookings for transactions with no upstream invoice/voucher to match
// against — the common "stuck on the unmatched list" cause (small ränteintäkter,
// bankavgifter, valutakursdifferenser). These reuse the existing bank_finance
// booking templates; the categorize endpoint rewrites the bank leg to the
// transaction's actual settlement account, so they book correctly on ANY cash
// account (1930, a savings account, a EUR account…), not just 1930.
// `account` is the non-bank leg (revenue/cost) — the bank leg is the selected
// account. Income templates apply to positive amounts, expense to negative.
const QUICK_BOOK_TEMPLATES: {
id: string
label: string
account: string
direction: 'income' | 'expense'
}[] = [
{ id: 'bank_interest_income', label: 'ränteintäkt', account: '8310', direction: 'income' },
{ id: 'bank_currency_gain', label: 'valutakursvinst', account: '3960', direction: 'income' },
{ id: 'bank_fees', label: 'bankavgift', account: '6570', direction: 'expense' },
{ id: 'bank_interest_expense', label: 'räntekostnad', account: '8410', direction: 'expense' },
{ id: 'bank_currency_loss', label: 'valutakursförlust', account: '7960', direction: 'expense' },
]
// ============================================================
// Types
// ============================================================
@@ -282,6 +305,13 @@ export function BankReconciliationView() {
const [showIgnored, setShowIgnored] = useState(true)
const [ignoredTx, setIgnoredTx] = useState<UnmatchedTransaction[]>([])
const [selectedMatch, setSelectedMatch] = useState<Record<string, string>>({})
// True when the unmatched list hit the API's 500-row cap — surfaced so a long
// date range doesn't silently hide rows and let the user think they're done.
const [unmatchedTruncated, setUnmatchedTruncated] = useState(false)
// Aborts the previous in-flight load when the account/date filters change, so
// a slow stale response can't overwrite the freshly-selected account's data
// (the intermittent "flips between accounts" bug).
const fetchAbortRef = useRef<AbortController | null>(null)
const { dialogProps: confirmDialogProps, confirm } = useDestructiveConfirm()
const { toast } = useToast()
@@ -308,6 +338,14 @@ export function BankReconciliationView() {
}, [])
const fetchAll = useCallback(async () => {
// Cancel any in-flight load — it may be for a different account. Without
// this, switching accounts quickly lets an older response land last and
// overwrite the current account's data.
fetchAbortRef.current?.abort()
const controller = new AbortController()
fetchAbortRef.current = controller
const { signal } = controller
setLoading(true)
setError(null)
try {
@@ -326,10 +364,10 @@ export function BankReconciliationView() {
const reconciledQs = `?reconciled=true&${txParams}`
const [statusRes, glRes, unmatchedRes, matchedRes] = await Promise.all([
fetch(`/api/reconciliation/bank/status${qs}`),
fetch(`/api/reconciliation/bank/unmatched-entries${qs}`),
fetch(`/api/transactions${unmatchedQs}`),
fetch(`/api/transactions${reconciledQs}`),
fetch(`/api/reconciliation/bank/status${qs}`, { signal }),
fetch(`/api/reconciliation/bank/unmatched-entries${qs}`, { signal }),
fetch(`/api/transactions${unmatchedQs}`, { signal }),
fetch(`/api/transactions${reconciledQs}`, { signal }),
])
const [statusData, glData, unmatchedData, matchedData] = await Promise.all([
@@ -339,10 +377,15 @@ export function BankReconciliationView() {
matchedRes.json(),
])
// A newer load superseded this one while we awaited — discard these
// stale results rather than clobber the current account's data.
if (signal.aborted) return
if (statusData.data) setStatus(statusData.data)
setGlLines(glData.data || [])
setUnmatchedTx(unmatchedData.data || [])
setMatchedTx(matchedData.data || [])
setUnmatchedTruncated(Boolean(unmatchedData.has_more))
// Refresh the ignored list whenever the main lists refresh.
// Deliberately NOT filtered by account or currency — if a user ignored
@@ -351,17 +394,21 @@ export function BankReconciliationView() {
// keeps the Återställ path reachable from any account selection. The
// date filter is also dropped so old ignores stay visible.
try {
const ignoredRes = await fetch(`/api/transactions?unmatched=true&only_ignored=true`)
const ignoredRes = await fetch(`/api/transactions?unmatched=true&only_ignored=true`, { signal })
const ignoredData = await ignoredRes.json()
setIgnoredTx(ignoredData.data || [])
if (!signal.aborted) setIgnoredTx(ignoredData.data || [])
} catch {
setIgnoredTx([])
if (!signal.aborted) setIgnoredTx([])
}
} catch (e) {
// Aborts are expected when the user switches account/date quickly.
if (signal.aborted || (e instanceof DOMException && e.name === 'AbortError')) return
console.error('[reconciliation] fetchAll failed', e)
setError('Kunde inte hämta avstämningsdata')
} finally {
setLoading(false)
// Only the latest load owns the spinner; a superseded load must not flip
// it off while the fresh one is still running.
if (!signal.aborted) setLoading(false)
}
}, [dateFrom, dateTo, accountNumber, accountCurrency])
@@ -369,6 +416,14 @@ export function BankReconciliationView() {
fetchAll()
}, [fetchAll])
// Reset transient per-account UI state when the selected account changes. A
// verifikation pick or a dry-run preview computed for the previous account is
// meaningless against the new one — and applying it would cross-link.
useEffect(() => {
setSelectedMatch({})
setDryRunResults(null)
}, [accountNumber])
const handleDryRun = async () => {
setRunLoading(true)
setDryRunResults(null)
@@ -428,6 +483,7 @@ export function BankReconciliationView() {
body: JSON.stringify({
transaction_id: transactionId,
journal_entry_id: journalEntryId,
account_number: accountNumber,
}),
})
const result = await res.json()
@@ -470,13 +526,15 @@ export function BankReconciliationView() {
}
/**
* Inline shortcut for the most common "stuck on the unmatched list" cause:
* a small ränteintäkt that has no upstream voucher to match against. Calls
* the standard categorize endpoint with the existing bank_interest_income
* Inline one-click booking for an unmatched transaction with no upstream
* voucher to match against (ränteintäkter, bankavgifter, valutakurs-
* differenser). Calls the standard categorize endpoint with a bank_finance
* template so the resulting verifikation is identical to the /transactions
* flow — no parallel booking path.
* flow — no parallel booking path. The categorize endpoint rewrites the bank
* leg to the transaction's actual settlement account, so this is correct on
* any cash account.
*/
const handleBookInterestIncome = async (transactionId: string) => {
const handleQuickBook = async (transactionId: string, templateId: string) => {
setActionLoading(transactionId)
try {
const res = await fetch(`/api/transactions/${transactionId}/categorize`, {
@@ -484,13 +542,13 @@ export function BankReconciliationView() {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
is_business: true,
template_id: 'bank_interest_income',
template_id: templateId,
confirm_no_match: true,
}),
})
const result = await res.json()
if (!res.ok || result.error) {
setError(result.error?.message || result.error || 'Kunde inte bokföra ränteintäkten')
setError(result.error?.message || result.error || 'Kunde inte bokföra transaktionen')
return
}
if (result.journal_entry_error) {
@@ -499,7 +557,7 @@ export function BankReconciliationView() {
}
await fetchAll()
} catch {
setError('Kunde inte bokföra ränteintäkten')
setError('Kunde inte bokföra transaktionen')
} finally {
setActionLoading(null)
}
@@ -773,14 +831,21 @@ export function BankReconciliationView() {
</p>
)}
</div>
{unmatchedTruncated && (
<p className="text-xs text-muted-foreground">
Visar de senaste 500 transaktionerna — begränsa datumintervallet för att se fler.
</p>
)}
<div className="space-y-3">
{unmatchedTx.map((tx) => {
// Piggy-bank shortcut hardcodes the 1930↔8310 ränteintäkt template,
// so only offer it on a SEK account using 1930. On EUR (1932) or
// other settlement accounts the booking would post the EUR amount
// to the SEK cash account — silently wrong, hide it.
const canBookInterest = tx.amount > 0 && accountNumber === '1930'
const isPositive = tx.amount > 0
// Quick-book options matching the transaction's direction. The
// bank leg books to the SELECTED account (the categorize endpoint
// rewrites it from the cash_account_id), so these are correct on
// any account, not just 1930.
const quickBooks = QUICK_BOOK_TEMPLATES.filter((t) =>
isPositive ? t.direction === 'income' : t.direction === 'expense',
)
return (
<div
key={tx.id}
@@ -826,19 +891,36 @@ export function BankReconciliationView() {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-72">
{canBookInterest && (
<DropdownMenuItem
onClick={() => handleBookInterestIncome(tx.id)}
disabled={actionLoading === tx.id}
>
<PiggyBank className="h-4 w-4" />
<div className="flex flex-col">
<span>Bokför som ränteintäkt</span>
<span className="text-xs text-muted-foreground">
1930 mot 8310, ingen moms
</span>
</div>
</DropdownMenuItem>
{quickBooks.length > 0 && (
<>
<DropdownMenuLabel className="text-[11px] font-normal uppercase tracking-wider text-muted-foreground">
Bokför direkt
</DropdownMenuLabel>
{quickBooks.map((t) => {
// Read as "debit mot credit": income debits the
// bank (selected account), credits revenue;
// expense debits the cost account, credits bank.
const legs = isPositive
? `${accountNumber} mot ${t.account}`
: `${t.account} mot ${accountNumber}`
return (
<DropdownMenuItem
key={t.id}
onClick={() => handleQuickBook(tx.id, t.id)}
disabled={actionLoading === tx.id}
>
<PiggyBank className="h-4 w-4" />
<div className="flex flex-col">
<span>Bokför som {t.label}</span>
<span className="text-xs text-muted-foreground tabular-nums">
{legs}
</span>
</div>
</DropdownMenuItem>
)
})}
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem
onClick={() => handleIgnore(tx)}