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
@@ -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(() => {
|
||||
|
||||
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user