feat(reconciliation): match migrated bank history against imported SIE verifikat (#1598)

* feat(reconciliation): match migrated bank history against imported SIE verifikat

A first-class Fortnox/SIE migrator path: after SIE import plus bank connect
or bank CSV upload, historical bank rows are auto-matched (>= 0.9) or
suggestion-matched (0.75-0.89, persisted for review) against the imported
verifikat, with a guided review surface, instead of landing as anonymous
"Att bokfora" rows.

Phase 0: per-cash-account unattended sweep (fixes #1298 cross-account
pooling); widen payment_match_log action CHECK with
linked_to_existing_voucher (silently unlogged since March).
Phase 1: potential_journal_entry_id/method/confidence on transactions with
CHECK + invalidation triggers; persistSuggestions in runReconciliation;
sweep after bank CSV import with SIE overlap (suppressing
auto-categorization); sweep summaries stamped on bank_connections and
bank_file_imports; POST /api/reconciliation/bank/confirm-suggestions with
per-pair server-side revalidation (voucher consumption + bank-leg amount
and direction).
Phase 2: "Granska forslag" review tab on Transactions with chunked bulk
confirm, per-row fallbacks, "Kor matchning igen" (all_accounts sweep mode,
mutually exclusive with dry_run), attn line, pre-migration row marker.
Phase 3: ImportResultStep dual CTA (bank connect + CSV), migrator variant
of the account-picker #917 nudge, sweep outcome on the onboarding
checklist bank step.

Non-selection apply runs on /api/reconciliation/bank/run now floor at 0.9
and persist the review band instead of auto-committing fuzzy matches.
Migrations already applied to staging under the same versions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(reconciliation): resolve PR review findings in one pass

Swedish accounting review (both previously-deferred holes closed):
- runReconciliation's >= 0.9 auto-apply now writes 'matched' to
  payment_match_log (behandlingshistorik, BFNAR 2013:2 kap 8); the bus
  event alone lands in the 30-day event_log and is not an audit record.
- The three match-route storno-conflict branches detach reconciliation
  links via unlinkReconciliation instead of storno-reversing the linked
  verifikat: a reconciliation link points at an independent verifikat
  that may evidence other affarshandelser, and a wholesale reversal is
  an over-broad rattelse (BFL 5 kap 5 §).
- Historical gap quantified on prod (read-only, recorded in DECISIONS):
  762 unlogged manual links across 52 companies since 2026-03-23.

CodeRabbit:
- confirm-suggestions route: maxDuration 300 for full 500-item batches.
- AccountPickerDialog: migrator-nudge buttons set lookbackTouched so the
  async gap-fill probe cannot override an explicit choice.
- enable-banking post-backfill sweep: persistSuggestions so the review
  band is not dropped.
- bank-file execute: sie_sweep stamp errors are logged, not swallowed.
- ImportResultStep: sandbox keeps the CSV CTA (file import works there).
- payment_match_log CHECK swap: NOT VALID + VALIDATE, no table scan
  under ACCESS EXCLUSIVE.
- logMatchEvent calls awaited (serverless can freeze unawaited work).
- DECISIONS.md stale version reference annotated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(reconciliation): defer reconciliation-link detach until the match commits

Round-2 review findings:
- CodeRabbit: the eager unlinkReconciliation call could orphan a
  transaction if the match flow failed after it. All three match routes
  now persist NOTHING up front: the final transaction update overwrites
  journal_entry_id and clears reconciliation_method in the same write,
  so any failure in between leaves the existing link intact. The release
  is logged as 'unmatched' after the commit.
- Swedish review: the auto_suggested logMatchEvent in runReconciliation
  is now awaited like every other audit write.
- DECISIONS entry split into compliance/CodeRabbit lines and updated to
  describe the deferred detach.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(reconciliation): literal reconciliation_method payloads for the phantom-column scanner

The conditional spreads introduced with the deferred detach pushed the
scanner's unresolvable-expression count past its ceiling (380 > 378).
reconciliation_method: null is correct unconditionally on a confirmed
invoice/supplier match (null is already the value on every row that was
not reconciliation-linked), so the payloads become plain literals the
guard can verify. No behavior change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-08-13 23:12:27 +02:00
committed by GitHub
parent 07e89d9b52
commit 08440fed94
35 changed files with 3223 additions and 99 deletions
+11
View File
@@ -901,6 +901,13 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-13] Underlag failures resolve the response where it fails (getResponseErrorMessage + status) and an expired session announces itself on the existing session-timeout BroadcastChannel, instead of a global authenticated-fetch wrapper: `throw new Error(json.error)` printed "[object Object]" for the structured envelope, so the middleware 401 on a backgrounded mobile tab surfaced as the generic "Något gick fel" with no way back; a wrapper would have to be threaded through every call site to buy the same thing here. Upload failures also post metadata (status, size, mime, resolved reason) to /api/log, the one API path exempt from the timeout gate, because a request answered before the route runs leaves nothing in the function logs and the user-reported failures were invisible there.
[2026-08-13] Oversized phone photos are re-encoded in the browser (2400px long edge, JPEG q0.85 stepping down) rather than raising a platform limit or streaming straight to storage: hosted rejects any request body over 4.5 MB itself (measured against prod: 4.4 MB reaches the route, 4.6 MB returns a plain-text FUNCTION_PAYLOAD_TOO_LARGE), before the route runs and therefore invisibly in the function logs, while the route advertises 10 MB it can never receive. A downscaled photo is still a faithful, durably readable reproduction (BFL 7 kap), which a refusal is not. What cannot be shrunk (PDF, or HEIC where the browser will not decode it) is refused client-side with its actual size named, and 413 was added to the HTTP status map so a rejection in transit still says what happened. Direct-to-storage upload, which would remove the ceiling for PDFs too, is the follow-up, not this fix: it moves sha256/WORM integrity off the server.
[2026-08-13] The book-route underlag fix landed as a pinned-document leg inside propagateUnderlagForBookedTransaction rather than the planned "extract categorize-core's propagation block into a shared helper": PR #1547 had already done that extraction overnight and wired /book and bulk-book to the shared helper, but the helper only walked matched inbox items, so a document pinned via transactions.document_id with no unconsumed inbox item (direct upload, or item consumed elsewhere) still booked to "Underlag saknas". Anchoring the pin inside the helper fixes /book, categorize, bulk-book and attach-after-book in one place; the pin is read fresh (not from the caller's pre-booking snapshot) so a concurrent attach still anchors, and the bulk-book RPC's own atomic doc-linking makes the leg a no-op there.
[2026-08-13] Bank↔SIE match suggestions stored as three columns on transactions (potential_journal_entry_id/method/confidence, mirroring potential_invoice_id) with DB-trigger invalidation, not a suggestions table: one candidate per row matches the greedy matcher's contract, the enrichment/consume/clear paths are the exact shape the invoice hints already use, and triggers (self-clear on link/ignore, sibling-clear on consumption, clear on storno reversal) cover every write path including MCP and RPCs where app-side clearing cannot.
[2026-08-13] /api/reconciliation/bank/run apply runs WITHOUT selected_matches now apply at the 0.9 unattended floor and persist the 0.75-0.89 band as suggestions, a deliberate behavior change from apply-everything-including-fuzzy: nobody reviewed those pairs, and auto-committing 0.75-fuzzy matches on an unreviewed run is exactly what the unattended threshold exists to prevent. Reviewed applies (selected_matches) keep the legacy no-floor behavior.
[2026-08-13] "Granska migrerad historik" is a conditional third tab on the existing Transactions page (renders only while suggestion rows exist), not a new page: the plan called for a filtered view, the rows are transactions, and a permanent surface would advertise a migrator-only flow to every company. Its attn line yields to the SKV-reconnect line (max one per page, convention 6).
[2026-08-13] The pre-migration row marker keys on sie_imports.fiscal_year_end, deliberately period-based: it labels which period a row belongs to. This is NOT the #917 trap (suggesting a sync-skip date from fiscal_year_end); the marker suggests nothing, and the account picker's migrator nudge uses fiscal_year_start to pull MORE history, never less.
[2026-08-13] Staging migrations (20260813120000 [file since renamed to 20260813210000, see the rename entry below], 20260813121000) applied via execute_sql with explicit supabase_migrations.schema_migrations inserts under the exact file versions, instead of MCP apply_migration: apply_migration stamps its own apply-time version, which would leave staging's history diverging from the repo files mid-reconcile (the drift Emil is currently draining). Prod untouched; it gets both files at merge.
[2026-08-13] /skeptic on bank-and-sie-match returned BLOCK (correctness + compliance skeptics refuted; regression could not). All four blocking defects fixed in the same worktree: bulk confirm now chunks at the schema's 500-id cap; all_accounts rejects (400) any combination with dry_run/account_number/selected_matches instead of silently applying on a requested preview; the sweep summary JSONB carries `errors` and the checklist note stays silent on an incomplete sweep; confirm revalidates the voucher's bank-leg amount AND direction at click time (closes the inline-rattelse staleness hole, since strike-and-replace keeps status posted and fires no invalidation trigger) and resolves NULL-cash_account_id rows via the PRIMARY cash account instead of hard-coded 1930.
[2026-08-13] Two pre-existing holes the skeptic surfaced were deliberately NOT fixed here (scope containment): (a) the >= 0.9 auto-apply in runReconciliation has never written payment_match_log ('matched' is only logged by the invoice-match routes), which fails the log's own BFL 7:1 charter and predates this feature; (b) the match-invoice routes' storno-conflict branch reverses ANY linked journal entry without checking reconciliation_method, so a reconciliation link to a multi-event SIE verifikat can trigger a whole-entry reversal. Both need their own issues/PRs; this feature widens their exposed population, so they should land before or with the migrator rollout.
[2026-08-13] Correction-chain depth guard measures depth by walking correction_of_id/reverses_id links (new lib/core/bookkeeping/correction-chain.ts), never by matching "Rättelse:" prefixes in descriptions: custom verifikationstext (allowed since #1031) would dodge any text-based check. Threshold 3, advisory with an explicit allow_deep_chain bypass on every surface (service option, REST body, MCP tool arg, UI confirm) per the standing soft-guard rule; Christoffer's agent chains hit 10 deep. The guard also fires at MCP staging time, not only at commit, so the agent reconsiders in the same turn instead of after approval.
[2026-08-13] The reverse (storno) UI on the journal detail page got the same catch-and-confirm bypass as the correction dialog even though the plan scoped UI work to the correction dialog: reverseEntry carries the same guard, and without a working "Återför ändå" the guard would dead-end in a toast, which the soft-guard rule forbids.
[2026-08-13] tools/list payload ceiling bumped 59K to 59.5K for the two allow_deep_chain schema properties (trimmed to one sentence first): the bypass is wire contract agents must discover to act on CORRECTION_CHAIN_TOO_DEEP, not trimmable prose.
@@ -941,3 +948,7 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-13] Per-account VAT treatment is class-aware; explicit values extend custom accounts while canonical accounts keep their static BAS momsdeklaration mapping. SIE #SRU and #KTYP never supply it because they encode tax-return fields and account class, not momsdeklaration treatment. VMB carries no default account rate because its VAT base is the margin, not gross sales.
[2026-08-13] Issue #1408 separates evidence classification from lock and VAT overlays, and treats import and correction sources as false positives for live-template provenance: 1,316 of 1,361 production signature matches came from those sources, so a broad account signature must not become a correction queue.
[2026-08-13] Issue #1408 does not route locked candidates through the current correctEntry service or an unlock: the service creates its storno in the original period, so a compliant locked-period correction needs a separately tested open-period storno and replacement path that preserves the original and full rättelse trail.
[2026-08-13] payment_match_log CHECK migration renamed 20260813120000 -> 20260813210000 at merge time: main gained its own 20260813120000 (fix_franvaro_audit_trigger_definer) while this branch was in flight, and identical versions abort the Supabase apply with a schema_migrations_pkey duplicate. Staging's tracking row was updated to the new version in the same step, freeing the 20260813120000 slot for main's migration when staging reconciles.
[2026-08-13] PR #1598, compliance findings closed with the rollout after the Swedish accounting review escalated them from follow-up to fix-with-rollout: (a) runReconciliation's >= 0.9 auto-apply now writes 'matched' to payment_match_log (behandlingshistorik, BFNAR 2013:2 kap 8); (b) the three match-route storno-conflict branches no longer storno-reverse a reconciliation-linked verifikat: a reconciliation link points at an independent verifikat that may evidence other affarshandelser, and a wholesale reversal is an over-broad rattelse (BFL 5 kap 5 §). The detach is DEFERRED (round 2, CodeRabbit): nothing is persisted up front; the final transaction update overwrites the pointer and clears reconciliation_method in the same write, so a failure anywhere in the match flow leaves the existing link intact, and the release is logged as 'unmatched' after the commit.
[2026-08-13] PR #1598, CodeRabbit findings: confirm-suggestions maxDuration 300; lookbackTouched on the migrator nudge buttons; persistSuggestions on main's post-backfill sweep; sie_sweep stamp errors logged; sandbox keeps the CSV CTA (file import works there); payment_match_log CHECK swap now NOT VALID + VALIDATE (no table-scan under ACCESS EXCLUSIVE); every logMatchEvent call awaited (serverless can freeze unawaited work).
[2026-08-13] Historical audit gap quantified on prod (read-only): 762 manual-method links across 52 companies since 2026-03-23 have no payment_match_log row (upper bound: includes linked_to_existing_voucher drops AND older unlogged manual paths). Not backfillable (the inserts never landed); the links themselves are intact on transactions. Recorded here as the explicit ops note the compliance review asked for.
+42 -1
View File
@@ -59,7 +59,7 @@ export default async function DashboardPage() {
supabase.from('customers').select('*', { count: 'exact', head: true }).eq('company_id', companyId),
supabase.from('invoices').select('*', { count: 'exact', head: true }).eq('company_id', companyId),
supabase.from('transactions').select('*', { count: 'exact', head: true }).eq('company_id', companyId),
supabase.from('bank_connections').select('id, status, consent_expires, bank_name').eq('company_id', companyId).eq('status', 'active'),
supabase.from('bank_connections').select('id, status, consent_expires, bank_name, last_sie_sweep').eq('company_id', companyId).eq('status', 'active'),
supabase.from('sie_imports').select('*', { count: 'exact', head: true }).eq('company_id', companyId).eq('status', 'completed'),
// Skatteverket tokens are user-scoped (one BankID identity per user) but
// carry the active company_id; either filter would work: we use user_id
@@ -158,6 +158,37 @@ export default async function DashboardPage() {
const userFirstName = profile?.full_name?.trim().split(/\s+/)[0] ?? null
// Latest SIE reconciliation-sweep summary across both history sources
// (PSD2 sync stamps bank_connections.last_sie_sweep; a bank-file import
// stamps bank_file_imports.sie_sweep). Feeds the checklist's bank step with
// "X matchade, Y att granska" so a migrator sees the sweep outcome without
// hunting for it. Best-effort: absent rows just render no note.
type SieSweepSummaryLite = {
auto_linked?: number
suggested?: number
unmatched?: number
errors?: number
ran_at?: string
}
const { data: latestFileSweep } = await supabase
.from('bank_file_imports')
.select('sie_sweep')
.eq('company_id', companyId)
.not('sie_sweep', 'is', null)
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle()
const sweepCandidates: SieSweepSummaryLite[] = [
...(bankConnections || [])
.map((c) => c.last_sie_sweep as SieSweepSummaryLite | null)
.filter((s): s is SieSweepSummaryLite => Boolean(s)),
...(latestFileSweep?.sie_sweep ? [latestFileSweep.sie_sweep as SieSweepSummaryLite] : []),
]
const sieSweep =
sweepCandidates.length > 0
? sweepCandidates.reduce((a, b) => ((a.ran_at ?? '') >= (b.ran_at ?? '') ? a : b))
: null
return (
<DashboardContent
companyId={companyId}
@@ -176,6 +207,16 @@ export default async function DashboardPage() {
}}
vatLine={vatLine}
emptyLedger={emptyLedger}
sieSweep={
sieSweep
? {
auto_linked: sieSweep.auto_linked ?? 0,
suggested: sieSweep.suggested ?? 0,
unmatched: sieSweep.unmatched ?? 0,
errors: sieSweep.errors ?? 0,
}
: null
}
/>
)
}
+273 -8
View File
@@ -42,7 +42,9 @@ import type {
ViewMode,
SourceFilter,
CategorizeHandler,
PotentialVoucher,
} from '@/components/transactions/transaction-types'
import { SuggestionReviewList } from '@/components/transactions/SuggestionReviewList'
import type {
SkattekontoBatchResult,
SkattekontoBatchRowResult,
@@ -182,7 +184,11 @@ function buildSupplierInvoiceMap(
// prod schema cache (see DECISIONS.md 2026-07-06).
async function fetchPotentialMatches(
supabase: SupabaseClient,
rows: { potential_invoice_id: string | null; potential_supplier_invoice_id: string | null }[],
rows: {
potential_invoice_id: string | null
potential_supplier_invoice_id: string | null
potential_journal_entry_id?: string | null
}[],
) {
const potentialInvoiceIds = Array.from(
new Set(rows.flatMap((t) => (t.potential_invoice_id ? [t.potential_invoice_id] : []))),
@@ -190,6 +196,9 @@ async function fetchPotentialMatches(
const potentialSupplierInvoiceIds = Array.from(
new Set(rows.flatMap((t) => (t.potential_supplier_invoice_id ? [t.potential_supplier_invoice_id] : []))),
)
const potentialJournalEntryIds = Array.from(
new Set(rows.flatMap((t) => (t.potential_journal_entry_id ? [t.potential_journal_entry_id] : []))),
)
// Chunked .in() lists (PostgREST .in() URL-length convention, same 150 as
// the underlag-status effect below): the caller may pass the full pending
@@ -206,7 +215,7 @@ async function fetchPotentialMatches(
// an unmatchable candidate must not reach the row or the match dialog, which
// would otherwise compare the transaction against a 0 kr remaining balance
// and call it a partial payment.
const [invoiceResults, supplierInvoiceResults] = await Promise.all([
const [invoiceResults, supplierInvoiceResults, voucherResults] = await Promise.all([
Promise.all(
chunks(potentialInvoiceIds).map((ids) =>
supabase
@@ -227,6 +236,19 @@ async function fetchPotentialMatches(
.gt('remaining_amount', 0),
),
),
// Journal-entry match suggestions (the sweep's 0.75-0.89 band). DB
// triggers clear the pointer when the entry is consumed or reversed, but
// the posted-status filter revalidates anyway: a suggestion that no
// longer resolves to a live verifikat must not reach the review surface.
Promise.all(
chunks(potentialJournalEntryIds).map((ids) =>
supabase
.from('journal_entries')
.select('id, voucher_series, voucher_number, entry_date, description')
.in('id', ids)
.eq('status', 'posted'),
),
),
])
// Non-fatal: the transaction list still renders without match hints, but
@@ -237,10 +259,31 @@ async function fetchPotentialMatches(
for (const r of supplierInvoiceResults) {
if (r.error) console.error('[fetchPotentialMatches] supplier_invoices query failed', r.error)
}
for (const r of voucherResults) {
if (r.error) console.error('[fetchPotentialMatches] journal_entries query failed', r.error)
}
const voucherMap: Record<string, PotentialVoucher> = {}
for (const je of voucherResults.flatMap((r) => (r.data ?? []) as Array<{
id: string
voucher_series: string
voucher_number: number
entry_date: string
description: string | null
}>)) {
voucherMap[je.id] = {
journal_entry_id: je.id,
voucher_series: je.voucher_series,
voucher_number: je.voucher_number,
entry_date: je.entry_date,
description: je.description,
}
}
return {
invoiceMap: buildInvoiceMap(invoiceResults.flatMap((r) => r.data ?? [])),
supplierInvoiceMap: buildSupplierInvoiceMap(supplierInvoiceResults.flatMap((r) => r.data ?? [])),
voucherMap,
}
}
@@ -500,6 +543,39 @@ export default function TransactionsPage() {
const refreshTransactionsInFlightRef = useRef(false)
const refreshTransactionsQueuedRef = useRef(false)
// "Kör matchning igen" in the review surface.
const [rerunningMatch, setRerunningMatch] = useState(false)
// End of the company's completed SIE-import coverage (latest
// fiscal_year_end). Drives the quiet "från perioden före din migrering"
// marker on inbox rows: period-based on purpose, it labels which period a
// row belongs to, it never suggests a sync skip date (that was #917).
const [sieCoverageEnd, setSieCoverageEnd] = useState<string | null>(null)
useEffect(() => {
if (!companyId) {
setSieCoverageEnd(null)
return
}
let cancelled = false
;(async () => {
const { data } = await supabase
.from('sie_imports')
.select('fiscal_year_end')
.eq('company_id', companyId)
.eq('status', 'completed')
.not('fiscal_year_end', 'is', null)
.order('fiscal_year_end', { ascending: false })
.limit(1)
.maybeSingle()
if (!cancelled) {
setSieCoverageEnd((data as { fiscal_year_end?: string } | null)?.fiscal_year_end || null)
}
})()
return () => {
cancelled = true
}
}, [companyId, supabase])
// Computed lists
const uncategorizedTransactions = useMemo(
() => transactions
@@ -513,6 +589,18 @@ export default function TransactionsPage() {
[exitingIds, transactions],
)
// Journal-entry match suggestions awaiting review (the migrator surface).
// potential_voucher is already revalidated (posted-only) at enrichment time,
// and DB triggers clear consumed/reversed suggestions, so this list is
// honest without extra queries.
const suggestionItems = useMemo(
() =>
transactions.filter(
(t) => t.potential_voucher && !t.journal_entry_id && !t.is_ignored && !exitingIds.has(t.id),
),
[exitingIds, transactions],
)
// Merged inbox: bank tx + SKV rows interleaved by date. Source filter
// narrows to one side. SKV rows always go after bank rows on the same
// date: bank tx tend to have invoice-match suggestions and we'd rather
@@ -891,7 +979,7 @@ export default function TransactionsPage() {
const windowIds = new Set(rows.map((r) => r.id))
const olderPending = (pendingRows ?? []).filter((r) => !windowIds.has(r.id))
const allRows = [...rows, ...olderPending].sort((a, b) => b.date.localeCompare(a.date))
const { invoiceMap, supplierInvoiceMap } = await fetchPotentialMatches(supabase, allRows)
const { invoiceMap, supplierInvoiceMap, voucherMap } = await fetchPotentialMatches(supabase, allRows)
// Re-check after the second await: a scope change during the match
// enrichment must also discard this response.
@@ -903,6 +991,9 @@ export default function TransactionsPage() {
potential_supplier_invoice: t.potential_supplier_invoice_id
? supplierInvoiceMap[t.potential_supplier_invoice_id]
: undefined,
potential_voucher: t.potential_journal_entry_id
? voucherMap[t.potential_journal_entry_id]
: undefined,
}))
setTransactions(transactionsWithInvoices)
@@ -973,7 +1064,7 @@ export default function TransactionsPage() {
setPagedThroughDate(txData.length >= PAGE_SIZE ? txData[txData.length - 1].date : null)
setHasMore(txData.length >= PAGE_SIZE)
const { invoiceMap, supplierInvoiceMap } = await fetchPotentialMatches(supabase, txData)
const { invoiceMap, supplierInvoiceMap, voucherMap } = await fetchPotentialMatches(supabase, txData)
// Same staleness rule after the enrichment await: the offsets above were
// written under this generation, but a newer fetch has already reset them.
@@ -988,6 +1079,9 @@ export default function TransactionsPage() {
potential_supplier_invoice: t.potential_supplier_invoice_id
? supplierInvoiceMap[t.potential_supplier_invoice_id]
: undefined,
potential_voucher: t.potential_journal_entry_id
? voucherMap[t.potential_journal_entry_id]
: undefined,
}))
// The page may overlap pending rows already merged into state: keep the
@@ -1939,6 +2033,132 @@ export default function TransactionsPage() {
}, 350)
}
// "Granska migrerad historik": confirm/reject persisted journal-entry match
// suggestions through the server-side revalidating bulk endpoint. Stale
// pairs come back as skipped, never as a failed batch: the toast reports
// both numbers honestly and the refresh re-derives the list from DB truth.
// Chunked at the schema's 500-id cap (same as BankReconciliationView's
// apply): a migrator's review list can exceed it, and one unchunked POST
// would 400 with zero progress on exactly the flagship bulk action.
const SUGGESTION_CHUNK_SIZE = 500
const confirmSuggestions = useCallback(
async (transactionIds: string[]) => {
let confirmed = 0
let skipped = 0
try {
for (let i = 0; i < transactionIds.length; i += SUGGESTION_CHUNK_SIZE) {
const chunk = transactionIds.slice(i, i + SUGGESTION_CHUNK_SIZE)
const response = await fetch('/api/reconciliation/bank/confirm-suggestions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ transaction_ids: chunk, action: 'confirm' }),
})
const payload = await response.json()
if (!response.ok) {
// Report the partial progress alongside the failure: chunks that
// already committed stay committed.
toast({
title: t('review_confirm_failed'),
description: getErrorMessage(payload, { context: 'transaction' }),
variant: 'destructive',
})
return
}
confirmed += (payload.data?.confirmed ?? []).length
skipped += (payload.data?.skipped ?? []).length
}
toast({
title: t('review_confirm_done_title', { count: confirmed }),
description:
skipped > 0
? t('review_confirm_done_skipped', { count: skipped })
: t('review_confirm_done_description'),
})
} catch (error) {
toast({
title: t('review_confirm_failed'),
description: getErrorMessage(error, { context: 'transaction' }),
variant: 'destructive',
})
} finally {
await refreshTransactions()
}
},
[refreshTransactions, t, toast],
)
const rejectSuggestions = useCallback(
async (transactionIds: string[]) => {
try {
for (let i = 0; i < transactionIds.length; i += SUGGESTION_CHUNK_SIZE) {
const chunk = transactionIds.slice(i, i + SUGGESTION_CHUNK_SIZE)
const response = await fetch('/api/reconciliation/bank/confirm-suggestions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ transaction_ids: chunk, action: 'reject' }),
})
if (!response.ok) {
const payload = await response.json()
toast({
title: t('review_reject_failed'),
description: getErrorMessage(payload, { context: 'transaction' }),
variant: 'destructive',
})
return
}
}
} catch (error) {
toast({
title: t('review_reject_failed'),
description: getErrorMessage(error, { context: 'transaction' }),
variant: 'destructive',
})
} finally {
await refreshTransactions()
}
},
[refreshTransactions, t, toast],
)
// "Kör matchning igen": full per-account sweep over all history. New >= 0.9
// matches auto-link; the review band lands back here as fresh suggestions.
const rerunMatching = useCallback(async () => {
setRerunningMatch(true)
try {
const response = await fetch('/api/reconciliation/bank/run', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ all_accounts: true }),
})
const payload = await response.json()
if (!response.ok) {
toast({
title: t('review_rerun_failed'),
description: getErrorMessage(payload, { context: 'transaction' }),
variant: 'destructive',
})
return
}
toast({
title: t('review_rerun_done_title'),
description: t('review_rerun_done_description', {
applied: payload.data?.applied ?? 0,
suggested: payload.data?.suggested ?? 0,
}),
})
} catch (error) {
toast({
title: t('review_rerun_failed'),
description: getErrorMessage(error, { context: 'transaction' }),
variant: 'destructive',
})
} finally {
setRerunningMatch(false)
await refreshTransactions()
}
}, [refreshTransactions, t, toast])
async function handleMatchInvoice(transactionId: string, invoiceId: string): Promise<boolean> {
try {
const response = await fetch(`/api/transactions/${transactionId}/match-invoice`, {
@@ -2940,11 +3160,19 @@ export default function TransactionsPage() {
<TransactionStatusBar onOpenCreateDialog={() => setIsDialogOpen(true)} />
{skvNeedsReconnect && (
{skvNeedsReconnect ? (
<AttnLine action={{ label: t('skv_reconnect_cta'), href: '/settings/tax' }}>
{t('skv_reconnect_body')}
</AttnLine>
)}
) : suggestionItems.length > 0 && mode !== 'review' ? (
// Migrated-history review nudge. Max one attn line per page
// (convention 6): the SKV reconnect line wins when both apply.
<AttnLine
action={{ label: t('review_attn_cta'), onClick: () => setMode('review') }}
>
{t('review_attn_body', { count: suggestionItems.length })}
</AttnLine>
) : null}
{/* Toolbar (concept order): [Att bokföra/Alla-seg] [sök] [Välj flera]
... [source ContextPicker far right] */}
@@ -2981,6 +3209,29 @@ export default function TransactionsPage() {
>
{t('mode_all')}
</button>
{/* Review tab exists only while suggestions do (or while the user is
standing in it after emptying the list): a permanent third tab
would advertise a migrator surface most companies never need. */}
{(suggestionItems.length > 0 || mode === 'review') && (
<button
type="button"
role="tab"
aria-selected={mode === 'review'}
onClick={() => setMode('review')}
className={`inline-flex items-center gap-1.5 rounded-md px-3.5 py-[5px] text-[12.5px] transition-colors duration-150 ${
mode === 'review'
? 'border border-border bg-card font-medium text-foreground'
: 'text-muted-foreground hover:text-foreground'
}`}
>
{t('mode_review')}
{suggestionItems.length > 0 && (
<span className="rounded-full bg-secondary px-1.5 text-[10px] font-medium tabular-nums">
{suggestionItems.length}
</span>
)}
</button>
)}
</div>
<div className="relative min-w-[220px] max-w-xs flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
@@ -3034,6 +3285,15 @@ export default function TransactionsPage() {
</div>
))}
</DataList>
) : mode === 'review' ? (
<SuggestionReviewList
items={suggestionItems}
onConfirm={confirmSuggestions}
onReject={rejectSuggestions}
onOpenMatchVoucher={openMatchVoucherDialog}
onRerunMatching={rerunMatching}
rerunning={rerunningMatch}
/>
) : mode === 'inbox' ? (
inboxItems.length === 0 ? (
searchTerm || sourceFilter !== 'all' || periodBounds ? (
@@ -3059,8 +3319,12 @@ export default function TransactionsPage() {
is usually a migration/import whose counterpart vouchers
already exist, and the only match affordance here is per-row.
Static text + count (no probe): the reconciliation preview is
the honest source of how many actually match. */}
{selectableInboxIds.length >= 5 && (
the honest source of how many actually match.
Yields to the review-suggestions attn at the top of the page
(max one ochre sentence per page): when the sweep has already
persisted suggestions, "Granska förslagen" is the more precise
destination for the same backlog. */}
{selectableInboxIds.length >= 5 && suggestionItems.length === 0 && (
<AttnLine
className="px-1 pb-3"
action={{
@@ -3190,6 +3454,7 @@ export default function TransactionsPage() {
onMoveCashAccount={openMoveAccountDialog}
cashAccounts={cashAccounts}
onToggleSelect={toggleBatchSelect}
preMigrationCutoff={sieCoverageEnd}
/>
) : (
<SkattekontoInboxCard
@@ -2,9 +2,9 @@ import { createClient, type SupabaseClient } from '@supabase/supabase-js'
import { NextResponse } from 'next/server'
import { syncAccountTransactions } from '@/extensions/general/enable-banking/lib/sync'
import {
runReconciliation,
DEFAULT_UNATTENDED_CONFIDENCE_THRESHOLD,
} from '@/lib/reconciliation/bank-reconciliation'
runUnattendedReconciliationSweep,
toSweepSummary,
} from '@/lib/reconciliation/unattended-sweep'
import {
isConsentExpiringSoon,
getDaysUntilExpiry,
@@ -261,22 +261,36 @@ export const GET = withCronContext('cron.bank_sync', async (_request, ctx) => {
const totalDuplicates = syncResults.reduce((sum, r) => sum + r.duplicates, 0)
const totalErrors = syncResults.reduce((sum, r) => sum + r.errors, 0)
// Batch reconciliation sweep when SIE overlap detected
// Batch reconciliation sweep when SIE overlap detected. One scoped run
// per enabled cash account (issue #1298): a pooled run matched every
// same-currency account's transactions against 1930's GL lines and could
// persist a cross-account journal_entry_id.
if (sieOverlap && totalImported > 0) {
try {
const reconResult = await runReconciliation(supabase, connection.company_id, connection.user_id, {
dateFrom: fromDate,
dateTo: toDate,
// Unattended run: nobody reviews a dry-run first, so never commit
// low-confidence (fuzzy / date-range) matches automatically.
confidenceThreshold: DEFAULT_UNATTENDED_CONFIDENCE_THRESHOLD,
})
const reconResult = await runUnattendedReconciliationSweep(
supabase,
connection.company_id,
connection.user_id,
{ dateFrom: fromDate, dateTo: toDate },
)
// Stamp the outcome so the UI can render "Vi matchade X av Y" and the
// review surface knows there is something to granska.
await supabase
.from('bank_connections')
.update({
last_sie_sweep: toSweepSummary(reconResult, { dateFrom: fromDate, dateTo: toDate }),
})
.eq('id', connection.id)
if (reconResult.applied > 0 || reconResult.skippedBelowThreshold > 0) {
ctx.log.info('batch reconciliation after sync', {
companyId: connection.company_id,
applied: reconResult.applied,
skippedBelowThreshold: reconResult.skippedBelowThreshold,
total: reconResult.matches.length,
accounts: reconResult.accounts.map((a) => ({
accountNumber: a.accountNumber,
applied: a.applied,
skippedBelowThreshold: a.skippedBelowThreshold,
})),
})
}
} catch {
@@ -0,0 +1,176 @@
/**
* Tests for POST /api/import/bank-file/execute, focused on the SIE-overlap
* behavior: a bank file covering a period a completed SIE import already
* booked must (a) suppress auto-categorization to prevent double-booking and
* (b) trigger the per-account reconciliation sweep and stamp its summary,
* because CSV is how a migrator gets pre-PSD2 history into the system.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers'
const { supabase, enqueue, reset } = createQueuedMockSupabase()
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
const getCompanyRoleMock = vi.fn()
vi.mock('@/lib/auth/require-write', () => ({
getCompanyRole: (...args: unknown[]) => getCompanyRoleMock(...args),
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
const ingestMock = vi.fn()
vi.mock('@/lib/transactions/ingest', () => ({
ingestTransactions: (...args: unknown[]) => ingestMock(...args),
}))
const sweepMock = vi.fn()
vi.mock('@/lib/reconciliation/unattended-sweep', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/reconciliation/unattended-sweep')>()
return {
...actual,
runUnattendedReconciliationSweep: (...args: unknown[]) => sweepMock(...args),
}
})
import { POST } from '../route'
const emptyParams = { params: Promise.resolve({}) }
function makeBody(overrides: Record<string, unknown> = {}) {
return {
transactions: [
{ date: '2025-03-10', description: 'Hyra mars', amount: -12000, currency: 'SEK' },
{ date: '2025-01-05', description: 'Kundbetalning', amount: 25000, currency: 'SEK' },
],
format: 'seb',
filename: 'kontoutdrag.csv',
file_hash: 'abc123',
skip_duplicates: true,
auto_categorize: true,
...overrides,
}
}
function emptyIngestResult(overrides: Record<string, unknown> = {}) {
return {
imported: 2,
duplicates: 0,
reconciled: 0,
auto_categorized: 0,
auto_matched_invoices: 0,
errors: 0,
transaction_ids: ['t-1', 't-2'],
...overrides,
}
}
function emptySweepResult(overrides: Record<string, unknown> = {}) {
return {
accounts: [],
applied: 1,
errors: 0,
skippedBelowThreshold: 1,
suggested: 1,
unmatched: 0,
...overrides,
}
}
describe('POST /api/import/bank-file/execute (SIE overlap)', () => {
beforeEach(() => {
vi.clearAllMocks()
reset()
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase })
getCompanyRoleMock.mockResolvedValue({ ok: true, role: 'owner', companyId: 'company-1' })
ingestMock.mockResolvedValue(emptyIngestResult())
sweepMock.mockResolvedValue(emptySweepResult())
})
it('returns 401 when unauthenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const request = createMockRequest('/api/import/bank-file/execute', {
method: 'POST',
body: makeBody(),
})
const response = await POST(request, emptyParams)
expect(response.status).toBe(401)
})
it('suppresses auto-categorization, runs the sweep over the file window, and stamps the summary on SIE overlap', async () => {
enqueue({ data: { id: 'import-1' } }) // bank_file_imports upsert
enqueue({ data: { id: 'sie-1' } }) // sie_imports overlap: found
enqueue({ data: null }) // bank_file_imports status update
enqueue({ data: null }) // sie_sweep stamp update
enqueue({ data: [{ id: 't-1' }, { id: 't-2' }] }) // imported tx for event
const request = createMockRequest('/api/import/bank-file/execute', {
method: 'POST',
body: makeBody(),
})
const response = await POST(request, emptyParams)
const { status } = await parseJsonResponse(response)
expect(status).toBe(200)
// Ingest was told to skip auto-categorization (double-booking guard).
const ingestOptions = ingestMock.mock.calls[0][4] as Record<string, unknown>
expect(ingestOptions.skipAutoCategorization).toBe(true)
// Sweep ran over the file's own date window (min/max of its rows).
expect(sweepMock).toHaveBeenCalledWith(supabase, 'company-1', 'user-1', {
dateFrom: '2025-01-05',
dateTo: '2025-03-10',
})
})
it('runs no sweep and keeps categorization when there is no SIE overlap', async () => {
enqueue({ data: { id: 'import-1' } }) // upsert
enqueue({ data: null }) // sie_imports overlap: none
enqueue({ data: null }) // status update
enqueue({ data: [{ id: 't-1' }] }) // imported tx for event
const request = createMockRequest('/api/import/bank-file/execute', {
method: 'POST',
body: makeBody(),
})
const response = await POST(request, emptyParams)
expect(response.status).toBe(200)
const ingestOptions = ingestMock.mock.calls[0][4] as Record<string, unknown>
expect(ingestOptions.skipAutoCategorization).toBeUndefined()
expect(sweepMock).not.toHaveBeenCalled()
})
it('never sweeps for a viewer (raw insert only)', async () => {
getCompanyRoleMock.mockResolvedValue({ ok: true, role: 'viewer', companyId: 'company-1' })
enqueue({ data: { id: 'import-1' } }) // upsert
enqueue({ data: { id: 'sie-1' } }) // overlap found
enqueue({ data: null }) // status update
enqueue({ data: [{ id: 't-1' }] }) // imported tx for event
const request = createMockRequest('/api/import/bank-file/execute', {
method: 'POST',
body: makeBody(),
})
const response = await POST(request, emptyParams)
expect(response.status).toBe(200)
const ingestOptions = ingestMock.mock.calls[0][4] as Record<string, unknown>
expect(ingestOptions.rawInsertOnly).toBe(true)
expect(sweepMock).not.toHaveBeenCalled()
})
})
+63
View File
@@ -10,6 +10,10 @@ import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import type { ParsedBankTransaction, BankFileFormatId } from '@/lib/import/bank-file/types'
import type { Transaction } from '@/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
import {
runUnattendedReconciliationSweep,
toSweepSummary,
} from '@/lib/reconciliation/unattended-sweep'
ensureInitialized()
@@ -102,9 +106,30 @@ export const POST = withRouteContext(
import_source: format === 'camt053' ? 'camt053' : `csv_${format}`,
}))
// Detect SIE overlap, mirroring the enable-banking sync paths: a bank
// file covering a period a completed SIE import already booked must be
// matched against the imported verifikat, not re-booked. CSV is the only
// way a migrator gets deep history (PSD2 windows stop at ~90 days), so
// this path is the primary one for the Fortnox/SIE migrator journey.
const fileDateFrom = transactions.map((t) => t.date).sort()[0] || undefined
const fileDateTo = transactions.map((t) => t.date).sort().reverse()[0] || undefined
let sieOverlap: { id: string } | null = null
if (fileDateFrom) {
const { data } = await supabase
.from('sie_imports')
.select('id')
.eq('company_id', companyId)
.eq('status', 'completed')
.gte('fiscal_year_end', fileDateFrom)
.limit(1)
.maybeSingle()
sieOverlap = data ?? null
}
const ingestOptions: IngestOptions = {}
if (settlement_account) ingestOptions.settlementAccount = settlement_account
if (role === 'viewer') ingestOptions.rawInsertOnly = true
if (sieOverlap) ingestOptions.skipAutoCategorization = true
const ingestResult = await ingestTransactions(supabase, companyId, user.id, rawTransactions, ingestOptions)
if (ingestResult.errors > 0 && ingestResult.first_error) {
@@ -133,6 +158,44 @@ export const POST = withRouteContext(
})
.eq('id', importRecord.id)
// SIE-overlap-gated reconciliation sweep (issue: no sweep fired after a
// bank CSV import, yet CSV is how a migrator gets pre-PSD2 history). One
// scoped run per enabled cash account; >= 0.9 auto-links, the 0.75-0.89
// band persists as reviewable suggestions. Viewers skip it: the sweep
// updates transactions, which viewers cannot do.
if (sieOverlap && ingestResult.imported > 0 && role !== 'viewer') {
try {
const sweepResult = await runUnattendedReconciliationSweep(supabase, companyId, user.id, {
dateFrom: fileDateFrom,
dateTo: fileDateTo,
})
const { error: stampError } = await supabase
.from('bank_file_imports')
.update({
sie_sweep: toSweepSummary(sweepResult, {
dateFrom: fileDateFrom,
dateTo: fileDateTo,
}),
})
.eq('id', importRecord.id)
if (stampError) {
// The links/suggestions are already written; only the UI summary
// is missing. Say so instead of letting the sweep look unrun.
opLog.warn('failed to stamp sie_sweep summary on bank_file_imports', stampError)
}
if (sweepResult.applied > 0 || sweepResult.suggested > 0) {
opLog.info('post-import SIE reconciliation sweep', {
applied: sweepResult.applied,
suggested: sweepResult.suggested,
unmatched: sweepResult.unmatched,
})
}
} catch (err) {
// Non-critical: rows stay in "Att bokföra" for manual matching.
opLog.warn('post-import SIE reconciliation sweep failed', err as Error)
}
}
if (ingestResult.imported > 0 && ingestResult.transaction_ids.length > 0) {
try {
const { data: importedTransactions } = await supabase
@@ -0,0 +1,151 @@
/**
* Tests for POST /api/reconciliation/bank/confirm-suggestions.
*
* Exercises the route through the real withRouteContext wrapper, mocking only
* its auth/company/write dependencies plus the suggestions service. Covers:
* 401, 403 viewer, validation (400), and both actions' happy paths.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers'
const { supabase, reset } = createQueuedMockSupabase()
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
const requireWriteMock = vi.fn()
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
}))
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
const confirmMock = vi.fn()
const rejectMock = vi.fn()
vi.mock('@/lib/reconciliation/suggestions', () => ({
confirmJournalEntrySuggestions: (...args: unknown[]) => confirmMock(...args),
rejectJournalEntrySuggestions: (...args: unknown[]) => rejectMock(...args),
}))
import { POST } from '../route'
const emptyParams = { params: Promise.resolve({}) }
const TX_1 = '11111111-1111-4111-8111-111111111111'
const TX_2 = '22222222-2222-4222-8222-222222222222'
describe('POST /api/reconciliation/bank/confirm-suggestions', () => {
beforeEach(() => {
vi.clearAllMocks()
reset()
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase })
requireWriteMock.mockResolvedValue({ ok: true })
confirmMock.mockResolvedValue({ confirmed: [TX_1], rejected: [], skipped: [] })
rejectMock.mockResolvedValue({ confirmed: [], rejected: [TX_1], skipped: [] })
})
it('returns 401 when unauthenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const request = createMockRequest('/api/reconciliation/bank/confirm-suggestions', {
method: 'POST',
body: { transaction_ids: [TX_1], action: 'confirm' },
})
const response = await POST(request, emptyParams)
expect(response.status).toBe(401)
expect(confirmMock).not.toHaveBeenCalled()
})
it('returns 403 for a viewer', async () => {
requireWriteMock.mockResolvedValue({
ok: false,
response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }),
})
const request = createMockRequest('/api/reconciliation/bank/confirm-suggestions', {
method: 'POST',
body: { transaction_ids: [TX_1], action: 'confirm' },
})
const response = await POST(request, emptyParams)
expect(response.status).toBe(403)
expect(confirmMock).not.toHaveBeenCalled()
})
it('rejects an empty transaction_ids array with 400', async () => {
const request = createMockRequest('/api/reconciliation/bank/confirm-suggestions', {
method: 'POST',
body: { transaction_ids: [], action: 'confirm' },
})
const response = await POST(request, emptyParams)
expect(response.status).toBe(400)
expect(confirmMock).not.toHaveBeenCalled()
})
it('rejects an unknown action with 400', async () => {
const request = createMockRequest('/api/reconciliation/bank/confirm-suggestions', {
method: 'POST',
body: { transaction_ids: [TX_1], action: 'maybe' },
})
const response = await POST(request, emptyParams)
expect(response.status).toBe(400)
})
it('confirms suggestions and reports skipped pairs in snake_case', async () => {
confirmMock.mockResolvedValue({
confirmed: [TX_1],
rejected: [],
skipped: [{ transactionId: TX_2, reason: 'voucher_consumed' }],
})
const request = createMockRequest('/api/reconciliation/bank/confirm-suggestions', {
method: 'POST',
body: { transaction_ids: [TX_1, TX_2], action: 'confirm' },
})
const response = await POST(request, emptyParams)
const { status, body } = await parseJsonResponse<{
data: {
confirmed: string[]
skipped: Array<{ transaction_id: string; reason: string }>
}
}>(response)
expect(status).toBe(200)
expect(body.data.confirmed).toEqual([TX_1])
expect(body.data.skipped).toEqual([
{ transaction_id: TX_2, reason: 'voucher_consumed', message: undefined },
])
expect(confirmMock).toHaveBeenCalledWith(supabase, 'company-1', 'user-1', [TX_1, TX_2])
expect(rejectMock).not.toHaveBeenCalled()
})
it('routes action=reject to the reject service', async () => {
const request = createMockRequest('/api/reconciliation/bank/confirm-suggestions', {
method: 'POST',
body: { transaction_ids: [TX_1], action: 'reject' },
})
const response = await POST(request, emptyParams)
const { status, body } = await parseJsonResponse<{ data: { rejected: string[] } }>(response)
expect(status).toBe(200)
expect(body.data.rejected).toEqual([TX_1])
expect(rejectMock).toHaveBeenCalledWith(supabase, 'company-1', 'user-1', [TX_1])
expect(confirmMock).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,53 @@
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { ConfirmJeSuggestionsSchema } from '@/lib/api/schemas'
import {
confirmJournalEntrySuggestions,
rejectJournalEntrySuggestions,
} from '@/lib/reconciliation/suggestions'
ensureInitialized()
// A full 500-item batch runs sequentially with several queries per pair
// (revalidation is the point), which can exceed the platform's default
// function budget. Same 5-minute allowance as the bank-file and SIE imports.
export const maxDuration = 300
/**
* POST /api/reconciliation/bank/confirm-suggestions
*
* Bulk confirm (or reject) journal-entry match suggestions written by the
* SIE reconciliation sweep. Confirming links each transaction to its suggested
* verifikat via the ordinary manual-link path with full server-side
* revalidation per pair; pairs that went stale between suggestion and click
* (entry reversed, verifikat consumed by another row, row booked elsewhere)
* are skipped and reported rather than failing the batch.
*/
export const POST = withRouteContext(
'reconciliation.bank.confirm_suggestions',
async (request, { supabase, user, companyId }) => {
const validation = await validateBody(request, ConfirmJeSuggestionsSchema)
if (!validation.success) return validation.response
const { transaction_ids, action } = validation.data
const result =
action === 'confirm'
? await confirmJournalEntrySuggestions(supabase, companyId, user.id, transaction_ids)
: await rejectJournalEntrySuggestions(supabase, companyId, user.id, transaction_ids)
return NextResponse.json({
data: {
confirmed: result.confirmed,
rejected: result.rejected,
skipped: result.skipped.map((s) => ({
transaction_id: s.transactionId,
reason: s.reason,
message: s.message,
})),
},
})
},
{ requireWrite: true },
)
@@ -31,6 +31,12 @@ vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
const runReconciliationMock = vi.fn()
vi.mock('@/lib/reconciliation/bank-reconciliation', () => ({
runReconciliation: (...args: unknown[]) => runReconciliationMock(...args),
DEFAULT_UNATTENDED_CONFIDENCE_THRESHOLD: 0.9,
}))
const sweepMock = vi.fn()
vi.mock('@/lib/reconciliation/unattended-sweep', () => ({
runUnattendedReconciliationSweep: (...args: unknown[]) => sweepMock(...args),
}))
import { POST } from '../route'
@@ -135,7 +141,11 @@ describe('POST /api/reconciliation/bank/run', () => {
)
})
it('omits the confidence threshold when the client does not send one', async () => {
it('defaults a no-selection apply to the 0.9 unattended floor when the client sends no threshold', async () => {
// Merged semantics (#1571 x bank-and-sie-match): an explicit
// confidence_threshold always wins; WITHOUT one, an apply with no
// selected_matches is effectively unattended, so it floors at 0.9 and
// persists the review band instead of auto-committing fuzzy matches.
// cash_accounts lookup: no row, '1930' default is exempt.
enqueue({ data: null })
@@ -150,7 +160,7 @@ describe('POST /api/reconciliation/bank/run', () => {
supabase,
'company-1',
'user-1',
expect.objectContaining({ confidenceThreshold: undefined }),
expect.objectContaining({ confidenceThreshold: 0.9, persistSuggestions: true }),
)
})
@@ -211,7 +221,117 @@ describe('POST /api/reconciliation/bank/run', () => {
supabase,
'company-1',
'user-1',
expect.objectContaining({ accountNumber: '1930', currency: 'SEK', dryRun: false }),
// A no-selection apply run persists the review band as suggestions
// behind the unattended confidence floor ("Kör matchning igen").
expect.objectContaining({
accountNumber: '1930',
currency: 'SEK',
dryRun: false,
confidenceThreshold: 0.9,
persistSuggestions: true,
}),
)
})
it('keeps the legacy no-threshold behavior for a reviewed selected_matches apply', async () => {
// cash_accounts lookup: no row, '1930' exempt.
enqueue({ data: null })
runReconciliationMock.mockResolvedValue({
matches: [],
applied: 0,
errors: 0,
skippedBelowThreshold: 0,
suggested: 0,
candidates: 0,
})
const request = createMockRequest('/api/reconciliation/bank/run', {
method: 'POST',
body: {
selected_matches: [
{
transaction_id: '11111111-1111-4111-8111-111111111111',
journal_entry_id: '22222222-2222-4222-8222-222222222222',
},
],
},
})
const response = await POST(request, emptyParams)
expect(response.status).toBe(200)
const options = runReconciliationMock.mock.calls[0][3] as Record<string, unknown>
expect(options.applyOnly).toHaveLength(1)
// The user already reviewed these pairs in the dry-run preview: no floor,
// no suggestion persistence.
expect(options.confidenceThreshold).toBeUndefined()
expect(options.persistSuggestions).toBeUndefined()
})
it('routes all_accounts to the per-account sweep ("Kör matchning igen")', async () => {
sweepMock.mockResolvedValue({
accounts: [
{ accountNumber: '1930', applied: 3, suggested: 1 },
{ accountNumber: '1931', applied: 1, suggested: 0 },
],
applied: 4,
errors: 0,
skippedBelowThreshold: 1,
suggested: 1,
unmatched: 2,
})
const request = createMockRequest('/api/reconciliation/bank/run', {
method: 'POST',
body: { all_accounts: true },
})
const response = await POST(request, emptyParams)
const { status, body } = await parseJsonResponse<{
data: { applied: number; suggested: number; unmatched: number }
}>(response)
expect(status).toBe(200)
expect(body.data.applied).toBe(4)
expect(body.data.suggested).toBe(1)
expect(body.data.unmatched).toBe(2)
expect(sweepMock).toHaveBeenCalledWith(supabase, 'company-1', 'user-1', {
dateFrom: undefined,
dateTo: undefined,
})
expect(runReconciliationMock).not.toHaveBeenCalled()
})
it('rejects all_accounts combined with dry_run: the sweep has no preview form and must never apply on a requested preview', async () => {
const request = createMockRequest('/api/reconciliation/bank/run', {
method: 'POST',
body: { all_accounts: true, dry_run: true },
})
const response = await POST(request, emptyParams)
expect(response.status).toBe(400)
expect(sweepMock).not.toHaveBeenCalled()
expect(runReconciliationMock).not.toHaveBeenCalled()
})
it('rejects all_accounts combined with account_number or selected_matches', async () => {
for (const body of [
{ all_accounts: true, account_number: '1930' },
{ all_accounts: true, confidence_threshold: 0.85 },
{
all_accounts: true,
selected_matches: [
{
transaction_id: '11111111-1111-4111-8111-111111111111',
journal_entry_id: '22222222-2222-4222-8222-222222222222',
},
],
},
]) {
const request = createMockRequest('/api/reconciliation/bank/run', { method: 'POST', body })
const response = await POST(request, emptyParams)
expect(response.status).toBe(400)
}
expect(sweepMock).not.toHaveBeenCalled()
})
})
+72 -6
View File
@@ -1,7 +1,11 @@
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { runReconciliation } from '@/lib/reconciliation/bank-reconciliation'
import {
runReconciliation,
DEFAULT_UNATTENDED_CONFIDENCE_THRESHOLD,
} from '@/lib/reconciliation/bank-reconciliation'
import { runUnattendedReconciliationSweep } from '@/lib/reconciliation/unattended-sweep'
import { validateBody } from '@/lib/api/validate'
import { RunReconciliationSchema } from '@/lib/api/schemas'
@@ -12,8 +16,60 @@ export const POST = withRouteContext(
async (request, { supabase, user, companyId }) => {
const validation = await validateBody(request, RunReconciliationSchema)
if (!validation.success) return validation.response
const { date_from, date_to, account_number, dry_run, selected_matches, confidence_threshold } =
validation.data
const {
date_from,
date_to,
account_number,
dry_run,
selected_matches,
confidence_threshold,
all_accounts,
} = validation.data
// "Kör matchning igen": the per-account sweep across every enabled cash
// account, exactly what the unattended post-sync path runs. New >= 0.9
// matches auto-link; the 0.75-0.89 band persists as suggestions.
//
// The sweep ALWAYS writes at its own fixed floor: there is no dry-run form
// of it, and silently ignoring dry_run (or a client-sent floor) here would
// turn a requested preview into applied links (the documented
// dry-run-gotcha P0 class). Enforce the mutual exclusion instead of just
// documenting it.
if (
all_accounts &&
(dry_run !== undefined ||
account_number ||
selected_matches ||
confidence_threshold !== undefined)
) {
return NextResponse.json(
{
error:
'all_accounts kan inte kombineras med dry_run, account_number, selected_matches eller confidence_threshold',
},
{ status: 400 },
)
}
if (all_accounts) {
const sweep = await runUnattendedReconciliationSweep(supabase, companyId, user.id, {
dateFrom: date_from,
dateTo: date_to,
})
return NextResponse.json({
data: {
applied: sweep.applied,
errors: sweep.errors,
suggested: sweep.suggested,
unmatched: sweep.unmatched,
skipped_below_threshold: sweep.skippedBelowThreshold,
accounts: sweep.accounts.map((a) => ({
account_number: a.accountNumber,
applied: a.applied,
suggested: a.suggested,
})),
},
})
}
const accountNumber = account_number ?? '1930'
@@ -52,9 +108,17 @@ export const POST = withRouteContext(
transactionId: m.transaction_id,
journalEntryId: m.journal_entry_id,
})),
// Server-side floor on the apply path (mirrors the v1 route): pairs the
// fresh re-run scores below it are skipped, not applied.
confidenceThreshold: confidence_threshold,
// Server-side floor on the apply path. A client-sent confidence_threshold
// always wins (mirrors the v1 route: pairs the fresh re-run scores below
// it are skipped, not applied). Without one: a no-selection apply run is
// effectively unattended, so it floors at 0.9 and persists the 0.75-0.89
// band as reviewable suggestions instead of auto-committing fuzzy
// matches; applyOnly runs keep the legacy no-floor behavior (the user
// already reviewed the pairs in the dry-run preview). Ignored on dry runs.
confidenceThreshold:
confidence_threshold ??
(selected_matches ? undefined : DEFAULT_UNATTENDED_CONFIDENCE_THRESHOLD),
...(selected_matches ? {} : { persistSuggestions: true }),
})
return NextResponse.json({
@@ -74,6 +138,8 @@ export const POST = withRouteContext(
})),
applied: result.applied,
errors: result.errors,
suggested: result.suggested,
skipped_below_threshold: result.skippedBelowThreshold,
dry_run: dry_run ?? false,
},
})
@@ -334,9 +334,26 @@ export const POST = withRouteContext(
})
}
// A RECONCILIATION link (reconciliation_method set) is not a conflicting
// booking: the entry it points at is an independent verifikat (SIE import,
// salary run, manual booking) that may evidence OTHER affärshändelser, and
// reversing it wholesale as a side effect of matching one payment would be
// an over-broad rättelse (BFL 5 kap 5 §: a correction is scoped to the
// actual error). Nothing is detached HERE: the final transaction update
// below overwrites the pointer and clears reconciliation_method in the
// same write, so a failure anywhere in between leaves the existing link
// fully intact instead of orphaning the row.
const priorReconciliationLink =
transaction.journal_entry_id && transaction.reconciliation_method
? {
journalEntryId: transaction.journal_entry_id as string,
method: transaction.reconciliation_method as string,
}
: null
// Storno conflicting auto-categorization JE before any other state change.
// If storno fails, return immediately: nothing else has been modified.
if (transaction.journal_entry_id) {
if (transaction.journal_entry_id && !priorReconciliationLink) {
try {
await reverseEntry(supabase, companyId, user.id, transaction.journal_entry_id)
@@ -730,6 +747,13 @@ export const POST = withRouteContext(
journal_entry_id: journalEntryId,
is_business: true,
category: 'income_services',
// The invoice match supersedes any prior reconciliation link: the
// stale method label must not survive the re-pointed journal_entry_id
// (deferred detach, see the priorReconciliationLink block above).
// Unconditional literal on purpose: null is already the value on every
// non-reconciliation-linked row, and a literal payload keeps the
// phantom-column scanner able to verify the column set.
reconciliation_method: null,
})
.eq('id', transactionId)
@@ -738,6 +762,20 @@ export const POST = withRouteContext(
return errorResponseFromCode('MATCH_INVOICE_LINK_TX_FAILED', txLog, { requestId })
}
// The deferred detach committed with the update above: record the release
// of the prior reconciliation link so the append-only trail shows the full
// transition (behandlingshistorik, BFNAR 2013:2 kap 8).
if (priorReconciliationLink) {
await logMatchEvent(supabase, user.id, transactionId, 'unmatched', {
invoiceId: invoice_id,
previousState: {
journal_entry_id: priorReconciliationLink.journalEntryId,
reconciliation_method: priorReconciliationLink.method,
},
newState: { journal_entry_id: journalEntryId, reconciliation_method: null },
})
}
logMatchEvent(supabase, user.id, transactionId, 'matched', {
invoiceId: invoice_id,
matchConfidence: 1.0,
@@ -412,7 +412,21 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
const { newPaidAmount, newRemaining, isFullyPaid, newStatus } = payment.plan
const paidAt = isFullyPaid ? paidAtFromDate(transaction.date) : null
if (transaction.journal_entry_id) {
// A RECONCILIATION link (reconciliation_method set) is not a conflicting
// booking: the entry is an independent verifikat that may evidence OTHER
// affärshändelser; reversing it wholesale would be an over-broad rättelse
// (BFL 5 kap 5 §). Nothing is detached here: the final transaction update
// overwrites the pointer and clears reconciliation_method in the same
// write, so a failure in between leaves the existing link intact.
const priorReconciliationLink =
transaction.journal_entry_id && transaction.reconciliation_method
? {
journalEntryId: transaction.journal_entry_id as string,
method: transaction.reconciliation_method as string,
}
: null
if (transaction.journal_entry_id && !priorReconciliationLink) {
try {
await reverseEntry(ctx.supabase, ctx.companyId!, ctx.userId, transaction.journal_entry_id)
const { error: clearErr } = await ctx.supabase
@@ -762,6 +776,10 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
potential_invoice_id: null,
journal_entry_id: journalEntryId,
is_business: true,
// The invoice match supersedes any prior reconciliation link (deferred
// detach, see the priorReconciliationLink block above). Unconditional:
// null is already the value on every non-reconciliation-linked row.
reconciliation_method: null,
}
if (existingTxCategory) txUpdate.category = existingTxCategory
@@ -777,6 +795,19 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
})
}
// Record the release of the prior reconciliation link now that the
// re-point has committed (behandlingshistorik, BFNAR 2013:2 kap 8).
if (priorReconciliationLink) {
await logMatchEvent(ctx.supabase, ctx.userId, txId, 'unmatched', {
invoiceId: invoice_id,
previousState: {
journal_entry_id: priorReconciliationLink.journalEntryId,
reconciliation_method: priorReconciliationLink.method,
},
newState: { journal_entry_id: journalEntryId, reconciliation_method: null },
})
}
logMatchEvent(ctx.supabase, ctx.userId, txId, 'matched', {
invoiceId: invoice_id,
matchConfidence: 1.0,
@@ -244,7 +244,21 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
// 2440/1930 supplier-invoice payment entry: two verifikationer for
// one affärshändelse violates BFL 5 kap 6 §. If storno fails, abort
// before any further state change.
if (transaction.journal_entry_id) {
// A RECONCILIATION link (reconciliation_method set) is not a conflicting
// booking: the entry is an independent verifikat that may evidence OTHER
// affärshändelser; reversing it wholesale would be an over-broad rättelse
// (BFL 5 kap 5 §). Nothing is detached here: the final transaction update
// overwrites the pointer and clears reconciliation_method in the same
// write, so a failure in between leaves the existing link intact.
const priorReconciliationLink =
transaction.journal_entry_id && transaction.reconciliation_method
? {
journalEntryId: transaction.journal_entry_id as string,
method: transaction.reconciliation_method as string,
}
: null
if (transaction.journal_entry_id && !priorReconciliationLink) {
try {
await reverseEntry(
ctx.supabase,
@@ -508,6 +522,12 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
potential_supplier_invoice_id: null,
journal_entry_id: journalEntryId,
is_business: true,
// The supplier-invoice match supersedes any prior reconciliation link
// (deferred detach, see the priorReconciliationLink block above).
// Unconditional literal on purpose: null is already the value on every
// non-reconciliation-linked row, and a literal payload keeps the
// phantom-column scanner able to verify the column set.
reconciliation_method: null,
})
.eq('id', txId)
.eq('company_id', ctx.companyId!)
@@ -517,6 +537,19 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
})
}
// Record the release of the prior reconciliation link now that the
// re-point has committed (behandlingshistorik, BFNAR 2013:2 kap 8).
if (priorReconciliationLink) {
await logMatchEvent(ctx.supabase, ctx.userId, txId, 'unmatched', {
supplierInvoiceId: supplier_invoice_id,
previousState: {
journal_entry_id: priorReconciliationLink.journalEntryId,
reconciliation_method: priorReconciliationLink.method,
},
newState: { journal_entry_id: journalEntryId, reconciliation_method: null },
})
}
// Propagate a document pinned to the transaction onto the payment
// verifikat, mirroring the dashboard route (BFL 5 kap 6 §). Guarded to
// unlinked current-version docs only: a doc already serving another
@@ -57,6 +57,9 @@ interface DashboardContentProps {
* started" instead of a false "all caught up".
*/
emptyLedger?: boolean
/** Latest SIE reconciliation-sweep outcome, for the checklist's bank step
* ("X matchade, Y att granska"). Null when no sweep has run. */
sieSweep?: { auto_linked: number; suggested: number; unmatched: number; errors: number } | null
}
/**
@@ -79,6 +82,7 @@ export default function DashboardContent({
agentBuilt = true,
vatLine = null,
emptyLedger = false,
sieSweep = null,
}: DashboardContentProps) {
const t = useTranslations('dashboard')
const hasAi = useCapability(CAPABILITY.ai)
@@ -137,6 +141,7 @@ export default function DashboardContent({
hasInboxItems={!!onboardingProgress?.hasInboxItems}
hasAgentBuilt={agentBuilt}
vatLine={vatLine}
sieSweep={sieSweep}
/>
{/* Build-assistant hero: shown only until the company has a verified
+39 -40
View File
@@ -114,13 +114,24 @@ export default function ImportResultStep({
bridge quiets down to the plain way onward there. */}
{!isSandbox && <p className="font-display text-base">{t('reveal_bridge')}</p>}
<div className="mt-3 flex flex-wrap items-center gap-3">
{!isSandbox && (
{/* Both migrator paths: PSD2 covers recent history (banks
cap the window around 90 days), CSV upload reaches the
older period the SIE file covers. Either way the
overlap is matched against the imported verifikat.
Sandbox hides only the live bank connection; file-based
import works there and its CTA stays. */}
{!isSandbox && hasBanking && (
<Button asChild size="sm">
<Link href={hasBanking ? '/import?mode=psd2' : '/import?mode=bank'}>
{t('reveal_cta_bank')}
</Link>
<Link href="/import?mode=psd2">{t('reveal_cta_bank')}</Link>
</Button>
)}
<Button
asChild
size="sm"
variant={!isSandbox && hasBanking ? 'outline' : 'default'}
>
<Link href="/import?mode=bank">{t('reveal_cta_csv')}</Link>
</Button>
<Link
href="/"
className="text-xs text-muted-foreground underline decoration-border underline-offset-4 transition-colors hover:text-foreground"
@@ -415,46 +426,34 @@ export default function ImportResultStep({
</Card>
)}
{/* Next steps */}
{result.success && (
{/* Next steps: the migrator bridge. Not instructions to read, an action
to take: fetch the bank history so it can be matched against the
verifikat that were just imported, instead of landing as anonymous
"Att bokföra" rows. */}
{result.success && !showReveal && (
<Card className="bg-muted/50">
<CardHeader>
<CardTitle className="text-base">Nästa steg</CardTitle>
<CardTitle className="text-base">{t('next_steps_title')}</CardTitle>
<CardDescription>{t('next_steps_match_copy')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-start gap-3">
<div className="w-6 h-6 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-sm font-medium">
1
</div>
<div>
<p className="font-medium">Granska importerade verifikationer</p>
<p className="text-sm text-muted-foreground">
Kontrollera att allt ser korrekt ut i bokföringslistan
</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="w-6 h-6 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-sm font-medium">
2
</div>
<div>
<p className="font-medium">Verifiera balanserna</p>
<p className="text-sm text-muted-foreground">
Jämför huvudboken med din tidigare bokföring
</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="w-6 h-6 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-sm font-medium">
3
</div>
<div>
<p className="font-medium">Fortsätt med ny bokföring</p>
<p className="text-sm text-muted-foreground">
Nu kan du börja lägga till nya transaktioner
</p>
</div>
<CardContent className="space-y-3">
<div className="flex flex-wrap items-center gap-3">
{/* Sandbox hides only the live bank connection; file-based
import works there and its CTA stays. */}
{!isSandbox && hasBanking && (
<Button asChild size="sm">
<Link href="/import?mode=psd2">{t('next_steps_cta_bank')}</Link>
</Button>
)}
<Button
asChild
size="sm"
variant={!isSandbox && hasBanking ? 'outline' : 'default'}
>
<Link href="/import?mode=bank">{t('next_steps_cta_csv')}</Link>
</Button>
</div>
<p className="text-sm text-muted-foreground">{t('next_steps_window_hint')}</p>
</CardContent>
</Card>
)}
@@ -29,6 +29,12 @@ interface NewUserChecklistProps {
hasAgentBuilt?: boolean
/** Personalized VAT-deadline line for the Skatteverket step (null = say nothing). */
vatLine?: VatDeadlineLine
/** Latest SIE reconciliation-sweep outcome: surfaces "X matchade, Y att
* granska" on the bank step so a migrator sees what the sweep did with
* their history. Null = no sweep has run, say nothing. A sweep with
* errors > 0 was incomplete (a whole account may have been skipped), so it
* also says nothing rather than presenting partial numbers as the result. */
sieSweep?: { auto_linked: number; suggested: number; unmatched: number; errors: number } | null
}
/**
@@ -69,6 +75,7 @@ export default function NewUserChecklist({
hasInboxItems = false,
hasAgentBuilt = false,
vatLine = null,
sieSweep = null,
}: NewUserChecklistProps) {
const t = useTranslations('initial_setup')
const router = useRouter()
@@ -292,6 +299,22 @@ export default function NewUserChecklist({
<LogoMark src="/logos/enable-banking-icon.png" name="Enable Banking" mono />
) : undefined
}
doneNote={
step2Done &&
sieSweep &&
sieSweep.errors === 0 &&
(sieSweep.auto_linked > 0 || sieSweep.suggested > 0) ? (
<Link
href="/transactions"
className="underline decoration-border underline-offset-4 transition-colors hover:text-foreground"
>
{t('step_bank_sweep_note', {
matched: sieSweep.auto_linked,
toReview: sieSweep.suggested,
})}
</Link>
) : undefined
}
>
{t('step_bank_description')}
</Step>
@@ -398,6 +421,7 @@ function Step({
badge,
action,
marks,
doneNote,
last = false,
children,
}: {
@@ -408,6 +432,10 @@ function Step({
badge?: string
action?: (variant: 'default' | 'outline') => React.ReactNode
marks?: React.ReactNode
/** Small note rendered next to the title once the step is DONE: the one
* exception to "done steps collapse to their title" (e.g. the bank step's
* sweep outcome, which is the payoff the migrator is waiting for). */
doneNote?: React.ReactNode
last?: boolean
children: React.ReactNode
}) {
@@ -455,6 +483,9 @@ function Step({
)}
</div>
{!done && action?.(open ? 'default' : 'outline')}
{done && doneNote && (
<span className="text-xs tabular-nums text-muted-foreground">{doneNote}</span>
)}
</div>
{open && (
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-2">
@@ -0,0 +1,182 @@
'use client'
import { useState } from 'react'
import { useTranslations } from 'next-intl'
import { MoreHorizontal, Loader2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { EmptyState } from '@/components/ui/empty-state'
import { TH_CLASS, TD_CLASS } from '@/components/ui/dry-table'
import { formatCurrency, formatDate, cn } from '@/lib/utils'
import type { TransactionWithInvoice } from './transaction-types'
/**
* "Granska migrerad historik": the review surface for journal-entry match
* suggestions the reconciliation sweep persisted (the 0.75-0.89 band). Each
* row shows the bank transaction beside its suggested verifikat; confirming
* goes through the server-side revalidating bulk endpoint, so a stale pair
* degrades to a reported skip, never a wrong link. Per-row fallbacks: open the
* match dialog to pick another verifikat, or reject the suggestion (the row
* returns to the ordinary "Att bokföra" flow as backstop).
*/
interface SuggestionReviewListProps {
items: TransactionWithInvoice[]
/** Bulk/single confirm: resolves when the API call and list refresh are done. */
onConfirm: (transactionIds: string[]) => Promise<void>
onReject: (transactionIds: string[]) => Promise<void>
onOpenMatchVoucher: (tx: TransactionWithInvoice) => void
onRerunMatching: () => Promise<void>
rerunning: boolean
}
export function SuggestionReviewList({
items,
onConfirm,
onReject,
onOpenMatchVoucher,
onRerunMatching,
rerunning,
}: SuggestionReviewListProps) {
const t = useTranslations('tx_review')
const [busyIds, setBusyIds] = useState<Set<string>>(new Set())
const [bulkBusy, setBulkBusy] = useState(false)
const runRows = async (ids: string[], action: (ids: string[]) => Promise<void>) => {
setBusyIds((prev) => new Set([...prev, ...ids]))
try {
await action(ids)
} finally {
setBusyIds((prev) => {
const next = new Set(prev)
for (const id of ids) next.delete(id)
return next
})
}
}
const confirmAll = async () => {
setBulkBusy(true)
try {
await runRows(items.map((i) => i.id), onConfirm)
} finally {
setBulkBusy(false)
}
}
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-[12.5px] text-muted-foreground">
{t('intro', { count: items.length })}
</p>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="outline"
disabled={rerunning || bulkBusy}
onClick={() => void onRerunMatching()}
>
{rerunning && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
{t('rerun')}
</Button>
<Button size="sm" disabled={bulkBusy || items.length === 0} onClick={() => void confirmAll()}>
{bulkBusy && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
{t('confirm_all', { count: items.length })}
</Button>
</div>
</div>
{items.length === 0 ? (
<EmptyState title={t('empty_title')} description={t('empty_description')} />
) : (
<div className="overflow-x-auto">
<table className="w-full border-collapse text-[13px]">
<thead>
<tr>
<th className={TH_CLASS}>{t('th_date')}</th>
<th className={TH_CLASS}>{t('th_description')}</th>
<th className={cn(TH_CLASS, 'text-right')}>{t('th_amount')}</th>
<th className={TH_CLASS}>{t('th_suggestion')}</th>
<th className={TH_CLASS} aria-label={t('th_actions')} />
</tr>
</thead>
<tbody className="stagger-enter">
{items.map((tx) => {
const busy = busyIds.has(tx.id)
const voucher = tx.potential_voucher
return (
<tr key={tx.id} className="group transition-colors duration-150 hover:bg-secondary/35">
<td className={cn(TD_CLASS, 'whitespace-nowrap tabular-nums text-muted-foreground')}>
{formatDate(tx.date)}
</td>
<td className={cn(TD_CLASS, 'max-w-[280px]')}>
<span className="block truncate">{tx.description}</span>
</td>
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right tabular-nums')}>
{formatCurrency(tx.amount, tx.currency)}
</td>
<td className={cn(TD_CLASS, 'whitespace-nowrap')}>
{voucher ? (
<span className="tabular-nums">
{voucher.voucher_series}-{voucher.voucher_number}
<span className="text-muted-foreground">
{' · '}
{formatDate(voucher.entry_date)}
{typeof tx.potential_match_confidence === 'number' && (
<> {' · '}{Math.round(tx.potential_match_confidence * 100)} %</>
)}
</span>
</span>
) : (
<span className="text-muted-foreground">{t('suggestion_gone')}</span>
)}
</td>
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right')}>
<div className="flex items-center justify-end gap-1">
<Button
size="sm"
variant="outline"
className="h-7 px-3.5 text-xs"
disabled={busy || bulkBusy || !voucher}
onClick={() => void runRows([tx.id], onConfirm)}
>
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : t('confirm')}
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="ghost"
className="h-7 w-7"
disabled={busy || bulkBusy}
aria-label={t('row_menu')}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onOpenMatchVoucher(tx)}>
{t('open_match_dialog')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => void runRows([tx.id], onReject)}>
{t('reject')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</div>
)
}
@@ -82,6 +82,11 @@ interface TransactionInboxCardProps {
* gates the move action, which is pointless with a single account. */
cashAccounts?: CashAccount[]
onToggleSelect: (id: string) => void
/** End date of the company's completed SIE-import coverage. Rows on or
* before it are pre-migration history: they most likely correspond to an
* already-imported verifikat, so the row carries a quiet marker steering
* toward matching rather than re-booking. */
preMigrationCutoff?: string | null
}
/**
@@ -109,6 +114,7 @@ export default function TransactionInboxCard({
onMoveCashAccount,
cashAccounts,
onToggleSelect,
preMigrationCutoff = null,
}: TransactionInboxCardProps) {
const t = useTranslations('tx_inbox_card')
const tMethod = useTranslations('tx_method')
@@ -299,6 +305,13 @@ export default function TransactionInboxCard({
Möjlig 19301630
</Badge>
)}
{/* Quiet pre-migration marker (muted text, not a chip: it is
context, not an exception state). ISO dates compare lexically. */}
{preMigrationCutoff && transaction.date <= preMigrationCutoff && (
<span className="shrink-0 text-xs text-muted-foreground">
{t('pre_migration_marker')}
</span>
)}
</span>
</td>
<td
+15 -2
View File
@@ -1,13 +1,26 @@
import type { Transaction, TransactionCategory, Invoice, Customer, SupplierInvoice, VatTreatment } from '@/types'
/** Revalidated journal-entry match suggestion hung onto a row (mirrors
* potential_invoice): present only when the suggested entry is still posted. */
export interface PotentialVoucher {
journal_entry_id: string
voucher_series: string
voucher_number: number
entry_date: string
description: string | null
}
// Shared transaction type with potential invoice data
export interface TransactionWithInvoice extends Transaction {
potential_invoice?: Invoice & { customer?: Customer }
potential_supplier_invoice?: SupplierInvoice
potential_voucher?: PotentialVoucher
}
// Page view modes
export type ViewMode = 'inbox' | 'history'
// Page view modes. 'review' is the migrator surface: rows whose sweep
// suggestion awaits confirmation ("Granska migrerad historik"); the tab only
// renders while such rows exist.
export type ViewMode = 'inbox' | 'history' | 'review'
export type HistoryFilter = 'all' | 'business' | 'private'
// Source filter (concept scene 10 account chooser), shared by both view modes:
// everything, one cash account ('acct:<id>'), bank rows not yet tied to a
@@ -95,6 +95,8 @@ export function AccountPickerDialog({
// user must see why and be able to correct the picks.
const [saveError, setSaveError] = useState<string | null>(null)
const [lastBookedDate, setLastBookedDate] = useState<string | null>(null)
// Earliest completed SIE import coverage start: present = migrator flow.
const [sieCoverageStart, setSieCoverageStart] = useState<string | null>(null)
const [chartAccounts, setChartAccounts] = useState<ChartAccount[]>([])
const [chartError, setChartError] = useState(false)
const [ledgerByUid, setLedgerByUid] = useState<Record<string, string>>({})
@@ -203,23 +205,43 @@ export function AccountPickerDialog({
// fiscal period's end, which can lie months past the last actually booked
// transaction and would make the user skip everything unbooked in between.
// Only matters on the initial activation flow: selection edits don't re-run sync.
//
// Alongside it: the earliest completed SIE import's coverage start. For a
// migrator the right move is the OPPOSITE of skipping the booked overlap:
// fetch the whole period and let the post-sync sweep match bank rows against
// the imported verifikat. The nudge below flips accordingly.
useEffect(() => {
if (!open || !isInitialSelection || !company?.id) {
setLastBookedDate(null)
setSieCoverageStart(null)
return
}
let cancelled = false
;(async () => {
const { data } = await supabase
.from('journal_entries')
.select('entry_date')
.eq('company_id', company.id)
.eq('status', 'posted')
.order('entry_date', { ascending: false })
.limit(1)
.maybeSingle()
const [entryRes, sieRes] = await Promise.all([
supabase
.from('journal_entries')
.select('entry_date')
.eq('company_id', company.id)
.eq('status', 'posted')
.order('entry_date', { ascending: false })
.limit(1)
.maybeSingle(),
supabase
.from('sie_imports')
.select('fiscal_year_start')
.eq('company_id', company.id)
.eq('status', 'completed')
.not('fiscal_year_start', 'is', null)
.order('fiscal_year_start', { ascending: true })
.limit(1)
.maybeSingle(),
])
if (cancelled) return
setLastBookedDate((data as { entry_date?: string } | null)?.entry_date || null)
setLastBookedDate((entryRes.data as { entry_date?: string } | null)?.entry_date || null)
setSieCoverageStart(
(sieRes.data as { fiscal_year_start?: string } | null)?.fiscal_year_start || null,
)
})()
return () => { cancelled = true }
}, [open, isInitialSelection, company?.id, supabase])
@@ -588,7 +610,65 @@ export function AccountPickerDialog({
</p>
</div>
{bookedCoverage && (
{sieCoverageStart ? (
<div className="space-y-2 rounded-md border border-border bg-background/60 p-3">
{/* Migrator flow: a completed SIE import exists, so the booked
overlap is exactly what the post-sync sweep matches bank
rows against. Pulling from the SIE year's start is the
recommended move; skipping the overlap (the non-migrator
nudge) would leave the imported verifikat unreconciled. */}
<div className="flex items-start justify-between gap-3">
<p className="text-xs text-muted-foreground">
Du har importerat bokföring. Hämta bankhistorik från{' '}
<span className="font-medium tabular-nums text-foreground">{sieCoverageStart}</span>{' '}
(importens början) matchar vi transaktionerna automatiskt mot din importerade
bokföring i stället för att bokföra dem igen.
</p>
<button
type="button"
className="shrink-0 text-xs text-foreground underline underline-offset-2"
onClick={() => {
// Explicit choice: the async gap-fill probe must not
// override it if it resolves after this click.
lookbackTouched.current = true
setLookbackMode('custom')
setCustomSubMode('date')
setCustomDate(sieCoverageStart)
}}
disabled={isSaving}
>
Hämta från detta datum
</button>
</div>
{daysBetween(sieCoverageStart) > 90 && (
<p className="text-xs text-muted-foreground">
De flesta banker lämnar bara ut ca 90 dagars historik via bankkopplingen. Når
hämtningen inte hela vägen tillbaka kan du ladda upp kontoutdrag (CSV) under{' '}
<span className="font-medium">Importera</span> för den äldre perioden, matchningen
fungerar likadant.
</p>
)}
{bookedCoverage && (
<p className="text-xs text-muted-foreground">
Vill du ändå hoppa över det som redan är bokfört kan du{' '}
<button
type="button"
className="text-foreground underline underline-offset-2"
onClick={() => {
lookbackTouched.current = true
setLookbackMode('custom')
setCustomSubMode('date')
setCustomDate(bookedCoverage.suggestedStartDate)
}}
disabled={isSaving}
>
börja från {bookedCoverage.suggestedStartDate}
</button>{' '}
i stället.
</p>
)}
</div>
) : bookedCoverage && (
<div className="flex items-start justify-between gap-3 rounded-md border border-border bg-background/60 p-3">
{/* Stated as a fact with an opt-in shortcut, not as "vi
föreslår": the selected default below is the fiscal-year
+32 -9
View File
@@ -13,6 +13,10 @@ import {
} from './lib/api-client'
import { syncAccountTransactions } from './lib/sync'
import { findReusableSessions, countLiveSiblings } from './lib/session-sharing'
import {
runUnattendedReconciliationSweep,
toSweepSummary,
} from '@/lib/reconciliation/unattended-sweep'
import {
runReconciliation,
DEFAULT_UNATTENDED_CONFIDENCE_THRESHOLD,
@@ -762,22 +766,38 @@ export const enableBankingExtension: Extension = {
// When SIE overlap is detected, run a batch reconciliation sweep.
// The greedy algorithm considers all candidates globally (highest-
// confidence first) and catches matches the inline per-transaction
// pass may have missed due to processing order.
// pass may have missed due to processing order. One scoped run per
// enabled cash account (issue #1298): the pooled run could persist a
// cross-account journal_entry_id.
// Skip for viewers: reconciliation updates transactions which viewers cannot do.
if (sieOverlap && totalImported > 0 && !isViewer) {
try {
const reconResult = await runReconciliation(supabase, companyId, user.id, {
dateFrom: fromDate,
dateTo: toDate,
// This sweep applies without a human reviewing a dry-run, so
// never commit low-confidence (fuzzy / date-range) matches.
confidenceThreshold: DEFAULT_UNATTENDED_CONFIDENCE_THRESHOLD,
})
const reconResult = await runUnattendedReconciliationSweep(
supabase,
companyId,
user.id,
{ dateFrom: fromDate, dateTo: toDate },
)
// Stamp the outcome so the UI can render "Vi matchade X av Y" and
// the review surface knows there is something to granska.
await supabase
.from('bank_connections')
.update({
last_sie_sweep: toSweepSummary(reconResult, {
dateFrom: fromDate,
dateTo: toDate,
}),
})
.eq('id', connection.id)
if (reconResult.applied > 0 || reconResult.skippedBelowThreshold > 0) {
log.info('Post-sync batch reconciliation matched additional transactions', {
applied: reconResult.applied,
skippedBelowThreshold: reconResult.skippedBelowThreshold,
total: reconResult.matches.length,
accounts: reconResult.accounts.map((a) => ({
accountNumber: a.accountNumber,
applied: a.applied,
skippedBelowThreshold: a.skippedBelowThreshold,
})),
})
}
} catch {
@@ -1483,6 +1503,9 @@ export const enableBankingExtension: Extension = {
// Unattended run: nobody reviews a dry-run first, so never
// commit low-confidence (fuzzy / date-range) matches.
confidenceThreshold: DEFAULT_UNATTENDED_CONFIDENCE_THRESHOLD,
// ...but don't DROP them either: the 0.75-0.89 band feeds
// the "Granska förslag" review surface.
persistSuggestions: true,
})
totalAutoMatched += reconResult.applied
if (reconResult.applied > 0 || reconResult.skippedBelowThreshold > 0) {
+15
View File
@@ -2207,6 +2207,13 @@ export const MarkOpeningBalanceSchema = z.object({
export const RunReconciliationSchema = z.object({
date_from: isoDate.optional(),
date_to: isoDate.optional(),
// Run the per-cash-account unattended sweep over every enabled cash account
// ("Kör matchning igen" in the review surface) instead of one account. The
// sweep always applies at the unattended threshold and persists suggestions;
// there is no dry-run form. The route REJECTS (400) any combination with
// dry_run, account_number or selected_matches rather than silently ignoring
// them: a request that asked for a preview must never apply writes.
all_accounts: z.boolean().optional(),
// BAS settlement account to reconcile against (e.g. '1930', '1932'). Defaults
// to '1930' server-side so existing clients stay correct.
account_number: accountNumber.optional(),
@@ -2231,6 +2238,14 @@ export const RunReconciliationSchema = z.object({
confidence_threshold: z.number().min(0).max(1).optional(),
})
// Confirm or reject persisted journal-entry match suggestions
// (transactions.potential_journal_entry_id). Each pair is revalidated
// server-side at confirm time; stale pairs are skipped, never failing the batch.
export const ConfirmJeSuggestionsSchema = z.object({
transaction_ids: z.array(uuid).min(1).max(500),
action: z.enum(['confirm', 'reject']),
})
// ============================================================
// Report query schemas
// ============================================================
@@ -605,6 +605,59 @@ describe('runReconciliation', () => {
expect(result.errors).toBe(0)
})
it('persists the below-threshold band as suggestions when persistSuggestions is set', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
// Fuzzy match: amount off by 1 öre on the exact date → 0.75 confidence,
// below the 0.9 unattended floor.
const tx = makeTransaction({ id: 'tx-1', amount: 1000.01, date: '2024-06-15', currency: 'SEK' })
const glLine: UnlinkedGLLine = makeGLLine({
line_id: 'line-1',
journal_entry_id: 'je-1',
debit_amount: 1000,
entry_date: '2024-06-15',
})
enqueue({ data: [glLine] }) // RPC: GL lines
enqueue({ data: [tx] }) // transactions
enqueue({ data: [{ id: 'tx-1' }] }) // suggestion update .select('id')
const result = await runReconciliation(supabase as never, 'company-1', 'user-1', {
confidenceThreshold: 0.9,
persistSuggestions: true,
})
expect(result.applied).toBe(0)
expect(result.skippedBelowThreshold).toBe(1)
expect(result.suggested).toBe(1)
expect(result.candidates).toBe(1)
})
it('does not count a suggestion whose optimistic-lock update matched zero rows', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
const tx = makeTransaction({ id: 'tx-1', amount: 1000.01, date: '2024-06-15', currency: 'SEK' })
const glLine: UnlinkedGLLine = makeGLLine({
line_id: 'line-1',
journal_entry_id: 'je-1',
debit_amount: 1000,
entry_date: '2024-06-15',
})
enqueue({ data: [glLine] })
enqueue({ data: [tx] })
// A concurrent writer booked the row: .is('journal_entry_id', null) → 0 rows.
enqueue({ data: [] })
const result = await runReconciliation(supabase as never, 'company-1', 'user-1', {
confidenceThreshold: 0.9,
persistSuggestions: true,
})
expect(result.suggested).toBe(0)
expect(result.skippedBelowThreshold).toBe(1)
})
it('counts a conflicted apply (0 rows updated) as an error, not applied', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
@@ -0,0 +1,280 @@
/**
* Tests for confirm/reject of persisted journal-entry match suggestions.
*
* The contract under test: every pair is revalidated server-side at confirm
* time (row state, voucher consumption, and the voucher's bank-leg amount and
* direction), stale pairs are skipped (never failing the batch), and a
* verifikat cannot be consumed twice within one bulk confirm.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
const { manualLinkMock } = vi.hoisted(() => ({ manualLinkMock: vi.fn() }))
vi.mock('../bank-reconciliation', async (importOriginal) => {
const actual = await importOriginal<typeof import('../bank-reconciliation')>()
return { ...actual, manualLink: manualLinkMock }
})
const { logMatchEventMock } = vi.hoisted(() => ({ logMatchEventMock: vi.fn() }))
vi.mock('@/lib/invoices/match-log', () => ({
logMatchEvent: (...args: unknown[]) => logMatchEventMock(...args),
}))
import {
confirmJournalEntrySuggestions,
rejectJournalEntrySuggestions,
} from '../suggestions'
/** Queue-based chainable supabase stub, same pattern as the reconciliation
* suite: each awaited chain consumes the next queued result. */
function createQueueMockSupabase() {
const resultQueue: { data: unknown; error: unknown }[] = []
const enqueue = (...results: { data?: unknown; error?: unknown }[]) => {
for (const r of results) {
resultQueue.push({ data: r.data ?? null, error: r.error ?? null })
}
}
const buildChain = (): unknown => {
const handler: ProxyHandler<object> = {
get(_target, prop) {
if (prop === 'then') {
const next = resultQueue.shift() ?? { data: null, error: null }
return (resolve: (v: unknown) => void) => resolve(next)
}
return (..._args: unknown[]) => buildChain()
},
}
return new Proxy({}, handler)
}
const supabase = {
from: vi.fn().mockImplementation(() => buildChain()),
rpc: vi.fn().mockImplementation(() => buildChain()),
}
return { supabase, enqueue }
}
const TX_1 = 'tx-1'
const TX_2 = 'tx-2'
const JE_1 = 'je-1'
const CA_ID = 'ca-1'
function suggestionRow(overrides: Record<string, unknown> = {}) {
return {
id: TX_1,
amount: -1000,
currency: 'SEK',
journal_entry_id: null,
potential_journal_entry_id: JE_1,
potential_match_method: 'auto_date_range',
potential_match_confidence: '0.85',
cash_account_id: CA_ID,
...overrides,
}
}
/** A voucher bank leg agreeing with suggestionRow's -1000 (credit = money out). */
function matchingLegs() {
return [{ debit_amount: 0, credit_amount: 1000, currency: null, amount_in_currency: null }]
}
describe('confirmJournalEntrySuggestions', () => {
beforeEach(() => {
vi.clearAllMocks()
manualLinkMock.mockResolvedValue({ success: true })
})
it('confirms a valid suggestion via manualLink against the row\'s own settlement account', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
enqueue({ data: suggestionRow() }) // fetch tx
enqueue({ data: [] }) // consumers of JE_1: none
enqueue({ data: { ledger_account: '1932' } }) // cash account resolve
enqueue({ data: matchingLegs() }) // amount revalidation legs
const result = await confirmJournalEntrySuggestions(supabase as never, 'company-1', 'user-1', [
TX_1,
])
expect(result.confirmed).toEqual([TX_1])
expect(result.skipped).toEqual([])
expect(manualLinkMock).toHaveBeenCalledWith(supabase, 'company-1', TX_1, JE_1, 'user-1', '1932')
expect(logMatchEventMock).toHaveBeenCalledWith(
supabase,
'user-1',
TX_1,
'linked_to_existing_voucher',
expect.objectContaining({ matchMethod: 'auto_date_range', matchConfidence: 0.85 }),
)
})
it('skips a transaction whose suggested verifikat is already consumed', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
enqueue({ data: suggestionRow() })
enqueue({ data: [{ id: 'other-tx' }] }) // someone already settles JE_1
const result = await confirmJournalEntrySuggestions(supabase as never, 'company-1', 'user-1', [
TX_1,
])
expect(result.confirmed).toEqual([])
expect(result.skipped).toEqual([{ transactionId: TX_1, reason: 'voucher_consumed' }])
expect(manualLinkMock).not.toHaveBeenCalled()
})
it('never fails the batch: second row suggesting the same verifikat is skipped after the first consumes it', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
// Row 1: full happy path.
enqueue({ data: suggestionRow() })
enqueue({ data: [] })
enqueue({ data: { ledger_account: '1930' } })
enqueue({ data: matchingLegs() })
// Row 2 (fetched AFTER row 1 linked): the sibling-clear trigger has wiped
// its suggestion, so it reads as no_suggestion.
enqueue({ data: suggestionRow({ id: TX_2, potential_journal_entry_id: null, potential_match_method: null, potential_match_confidence: null }) })
const result = await confirmJournalEntrySuggestions(supabase as never, 'company-1', 'user-1', [
TX_1,
TX_2,
])
expect(result.confirmed).toEqual([TX_1])
expect(result.skipped).toEqual([{ transactionId: TX_2, reason: 'no_suggestion' }])
expect(manualLinkMock).toHaveBeenCalledTimes(1)
})
it('reports manualLink refusals as link_failed with the service message', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
enqueue({ data: suggestionRow() })
enqueue({ data: [] })
enqueue({ data: { ledger_account: '1930' } })
enqueue({ data: matchingLegs() })
manualLinkMock.mockResolvedValue({ success: false, error: 'Verifikationen är inte bokförd ännu.' })
const result = await confirmJournalEntrySuggestions(supabase as never, 'company-1', 'user-1', [
TX_1,
])
expect(result.confirmed).toEqual([])
expect(result.skipped).toEqual([
{ transactionId: TX_1, reason: 'link_failed', message: 'Verifikationen är inte bokförd ännu.' },
])
})
it('skips not-found and already-linked rows', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
enqueue({ data: null }) // TX_1 not found
enqueue({ data: suggestionRow({ id: TX_2, journal_entry_id: 'je-existing' }) })
const result = await confirmJournalEntrySuggestions(supabase as never, 'company-1', 'user-1', [
TX_1,
TX_2,
])
expect(result.confirmed).toEqual([])
expect(result.skipped).toEqual([
{ transactionId: TX_1, reason: 'not_found' },
{ transactionId: TX_2, reason: 'already_linked' },
])
})
it('resolves NULL-cash_account_id rows via the PRIMARY cash account, falling back to 1930', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
enqueue({ data: suggestionRow({ cash_account_id: null }) })
enqueue({ data: [] })
// Primary lookup: the company's primary account is 1920, so the unassigned
// row must validate against 1920 (the sweep created the suggestion under
// the primary's scope), never a hard-coded 1930.
enqueue({ data: { ledger_account: '1920' } })
enqueue({ data: matchingLegs() })
await confirmJournalEntrySuggestions(supabase as never, 'company-1', 'user-1', [TX_1])
expect(manualLinkMock).toHaveBeenCalledWith(supabase, 'company-1', TX_1, JE_1, 'user-1', '1920')
})
it('falls back to 1930 when no primary cash account exists', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
enqueue({ data: suggestionRow({ cash_account_id: null }) })
enqueue({ data: [] })
enqueue({ data: null }) // no primary row
enqueue({ data: matchingLegs() })
await confirmJournalEntrySuggestions(supabase as never, 'company-1', 'user-1', [TX_1])
expect(manualLinkMock).toHaveBeenCalledWith(supabase, 'company-1', TX_1, JE_1, 'user-1', '1930')
})
it('skips with amount_mismatch when the voucher bank leg no longer agrees with the transaction', async () => {
// Inline rattelse re-priced the voucher's bank leg from 1000 to 500 after
// the suggestion was computed: status stays posted, so no invalidation
// trigger fired. Confirm must refuse instead of asserting a false
// correspondence (BFL 5 kap 7 §).
const { supabase, enqueue } = createQueueMockSupabase()
enqueue({ data: suggestionRow() })
enqueue({ data: [] })
enqueue({ data: { ledger_account: '1930' } })
enqueue({ data: [{ debit_amount: 0, credit_amount: 500, currency: null, amount_in_currency: null }] })
const result = await confirmJournalEntrySuggestions(supabase as never, 'company-1', 'user-1', [
TX_1,
])
expect(result.confirmed).toEqual([])
expect(result.skipped).toHaveLength(1)
expect(result.skipped[0]).toMatchObject({ transactionId: TX_1, reason: 'amount_mismatch' })
expect(manualLinkMock).not.toHaveBeenCalled()
})
it('skips with amount_mismatch when the direction flipped', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
enqueue({ data: suggestionRow({ amount: 1000 }) }) // money IN
enqueue({ data: [] })
enqueue({ data: { ledger_account: '1930' } })
enqueue({ data: matchingLegs() }) // credit leg = money OUT
const result = await confirmJournalEntrySuggestions(supabase as never, 'company-1', 'user-1', [
TX_1,
])
expect(result.skipped[0]).toMatchObject({ transactionId: TX_1, reason: 'amount_mismatch' })
expect(manualLinkMock).not.toHaveBeenCalled()
})
})
describe('rejectJournalEntrySuggestions', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('clears the suggestion and logs suggestion_cleared with the previous state', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
enqueue({ data: suggestionRow() })
enqueue({ data: null }) // clear update
const result = await rejectJournalEntrySuggestions(supabase as never, 'company-1', 'user-1', [
TX_1,
])
expect(result.rejected).toEqual([TX_1])
expect(logMatchEventMock).toHaveBeenCalledWith(
supabase,
'user-1',
TX_1,
'suggestion_cleared',
expect.objectContaining({
previousState: expect.objectContaining({ potential_journal_entry_id: JE_1 }),
}),
)
})
it('skips rows without a suggestion', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
enqueue({ data: suggestionRow({ potential_journal_entry_id: null }) })
const result = await rejectJournalEntrySuggestions(supabase as never, 'company-1', 'user-1', [
TX_1,
])
expect(result.rejected).toEqual([])
expect(result.skipped).toEqual([{ transactionId: TX_1, reason: 'no_suggestion' }])
})
})
@@ -0,0 +1,249 @@
/**
* Tests for the per-cash-account unattended reconciliation sweep (issue #1298).
*
* The sweep is the shared entry point for every unattended caller (EB cron,
* manual EB sync, bank-file import). What matters here is the fan-out contract:
* one scoped runReconciliation call per enabled cash account, the legacy
* 1930/SEK fallback for companies with no cash_accounts rows, per-account
* failure isolation, and honest aggregation.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
const { runReconciliationMock } = vi.hoisted(() => ({
runReconciliationMock: vi.fn(),
}))
vi.mock('../bank-reconciliation', async (importOriginal) => {
const actual = await importOriginal<typeof import('../bank-reconciliation')>()
return {
...actual,
runReconciliation: runReconciliationMock,
}
})
import { runUnattendedReconciliationSweep, toSweepSummary } from '../unattended-sweep'
import { DEFAULT_UNATTENDED_CONFIDENCE_THRESHOLD } from '../bank-reconciliation'
function emptyRunResult(overrides: Partial<{
applied: number
errors: number
skippedBelowThreshold: number
suggested: number
candidates: number
matches: unknown[]
}> = {}) {
return {
matches: overrides.matches ?? [],
applied: overrides.applied ?? 0,
errors: overrides.errors ?? 0,
skippedBelowThreshold: overrides.skippedBelowThreshold ?? 0,
suggested: overrides.suggested ?? 0,
candidates: overrides.candidates ?? 0,
}
}
/** Chainable stub for the cash_accounts lookup: every method returns the chain,
* awaiting it resolves the configured result. */
function makeSupabase(result: { data: unknown; error: unknown }) {
const chain: Record<string, unknown> = {}
const handler: ProxyHandler<object> = {
get(_target, prop) {
if (prop === 'then') {
return (resolve: (v: unknown) => void) => resolve(result)
}
return () => new Proxy(chain, handler)
},
}
return {
from: vi.fn().mockImplementation(() => new Proxy(chain, handler)),
}
}
const CA_1930 = {
id: '11111111-1111-4111-8111-111111111111',
ledger_account: '1930',
currency: 'SEK',
is_primary: true,
}
const CA_1931 = {
id: '22222222-2222-4222-8222-222222222222',
ledger_account: '1931',
currency: 'SEK',
is_primary: false,
}
const CA_1932_EUR = {
id: '33333333-3333-4333-8333-333333333333',
ledger_account: '1932',
currency: 'EUR',
is_primary: false,
}
describe('runUnattendedReconciliationSweep', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('runs once per enabled cash account with that account\'s scope', async () => {
const supabase = makeSupabase({ data: [CA_1930, CA_1931, CA_1932_EUR], error: null })
runReconciliationMock.mockResolvedValue(emptyRunResult())
const result = await runUnattendedReconciliationSweep(
supabase as never,
'company-1',
'user-1',
{ dateFrom: '2026-01-01', dateTo: '2026-06-30' },
)
expect(runReconciliationMock).toHaveBeenCalledTimes(3)
// Primary 1930: claims unassigned NULL-cash_account_id rows.
expect(runReconciliationMock).toHaveBeenNthCalledWith(1, supabase, 'company-1', 'user-1', {
dateFrom: '2026-01-01',
dateTo: '2026-06-30',
accountNumber: '1930',
currency: 'SEK',
cashAccountId: CA_1930.id,
includeUnassigned: true,
confidenceThreshold: DEFAULT_UNATTENDED_CONFIDENCE_THRESHOLD,
persistSuggestions: true,
})
// Non-primary same-currency 1931: strict scope, never claims NULL rows.
expect(runReconciliationMock).toHaveBeenNthCalledWith(2, supabase, 'company-1', 'user-1', {
dateFrom: '2026-01-01',
dateTo: '2026-06-30',
accountNumber: '1931',
currency: 'SEK',
cashAccountId: CA_1931.id,
includeUnassigned: false,
confidenceThreshold: DEFAULT_UNATTENDED_CONFIDENCE_THRESHOLD,
persistSuggestions: true,
})
// Foreign account reconciles in its own currency.
expect(runReconciliationMock).toHaveBeenNthCalledWith(3, supabase, 'company-1', 'user-1', {
dateFrom: '2026-01-01',
dateTo: '2026-06-30',
accountNumber: '1932',
currency: 'EUR',
cashAccountId: CA_1932_EUR.id,
includeUnassigned: false,
confidenceThreshold: DEFAULT_UNATTENDED_CONFIDENCE_THRESHOLD,
persistSuggestions: true,
})
expect(result.accounts).toHaveLength(3)
})
it('falls back to a single legacy 1930/SEK run when the company has no cash_accounts rows', async () => {
const supabase = makeSupabase({ data: [], error: null })
runReconciliationMock.mockResolvedValue(emptyRunResult({ applied: 2 }))
const result = await runUnattendedReconciliationSweep(supabase as never, 'company-1', 'user-1')
expect(runReconciliationMock).toHaveBeenCalledTimes(1)
expect(runReconciliationMock).toHaveBeenCalledWith(supabase, 'company-1', 'user-1', {
dateFrom: undefined,
dateTo: undefined,
accountNumber: '1930',
currency: 'SEK',
cashAccountId: undefined,
includeUnassigned: true,
confidenceThreshold: DEFAULT_UNATTENDED_CONFIDENCE_THRESHOLD,
persistSuggestions: true,
})
expect(result.applied).toBe(2)
expect(result.accounts[0].cashAccountId).toBeNull()
})
it('aggregates per-account results into sweep totals', async () => {
const supabase = makeSupabase({ data: [CA_1930, CA_1931], error: null })
runReconciliationMock
.mockResolvedValueOnce(
emptyRunResult({ applied: 3, skippedBelowThreshold: 2, matches: [1, 2, 3, 4, 5] }),
)
.mockResolvedValueOnce(emptyRunResult({ applied: 1, errors: 1, matches: [1, 2] }))
const result = await runUnattendedReconciliationSweep(supabase as never, 'company-1', 'user-1')
expect(result.applied).toBe(4)
expect(result.errors).toBe(1)
expect(result.skippedBelowThreshold).toBe(2)
expect(result.accounts[0]).toMatchObject({ accountNumber: '1930', applied: 3, proposed: 5 })
expect(result.accounts[1]).toMatchObject({ accountNumber: '1931', applied: 1, proposed: 2 })
})
it('aggregates suggested and derives unmatched from the candidate pool', async () => {
const supabase = makeSupabase({ data: [CA_1930, CA_1931], error: null })
runReconciliationMock
.mockResolvedValueOnce(
emptyRunResult({ applied: 5, suggested: 2, candidates: 10, matches: [1, 2, 3, 4, 5, 6, 7] }),
)
.mockResolvedValueOnce(emptyRunResult({ applied: 1, suggested: 0, candidates: 3, matches: [1] }))
const result = await runUnattendedReconciliationSweep(supabase as never, 'company-1', 'user-1')
expect(result.applied).toBe(6)
expect(result.suggested).toBe(2)
// 13 candidates, 6 auto-linked, 2 suggested: 5 rows left for the backstop.
expect(result.unmatched).toBe(5)
})
it('one account failing does not abort the others; the failure counts as one error', async () => {
const supabase = makeSupabase({ data: [CA_1930, CA_1931], error: null })
runReconciliationMock
.mockRejectedValueOnce(new Error('transient'))
.mockResolvedValueOnce(emptyRunResult({ applied: 2, matches: [1, 2] }))
const result = await runUnattendedReconciliationSweep(supabase as never, 'company-1', 'user-1')
expect(runReconciliationMock).toHaveBeenCalledTimes(2)
expect(result.errors).toBe(1)
expect(result.applied).toBe(2)
expect(result.accounts[0]).toMatchObject({ accountNumber: '1930', applied: 0, errors: 1 })
expect(result.accounts[1]).toMatchObject({ accountNumber: '1931', applied: 2, errors: 0 })
})
it('throws when the cash_accounts lookup fails (never degrades to the pooled run)', async () => {
const supabase = makeSupabase({ data: null, error: { message: 'rls denied' } })
await expect(
runUnattendedReconciliationSweep(supabase as never, 'company-1', 'user-1'),
).rejects.toThrow('Kunde inte hämta kassakonton')
expect(runReconciliationMock).not.toHaveBeenCalled()
})
it('carries errors into the stamped summary so a crashed account never reads as "all done"', async () => {
const supabase = makeSupabase({ data: [CA_1930, CA_1931], error: null })
runReconciliationMock
.mockResolvedValueOnce(emptyRunResult({ applied: 5, candidates: 5, matches: [1, 2, 3, 4, 5] }))
// 1931 throws: its candidates never enter the pool, so unmatched: 0
// would be a lie without the errors field.
.mockRejectedValueOnce(new Error('transient'))
const result = await runUnattendedReconciliationSweep(supabase as never, 'company-1', 'user-1', {
dateFrom: '2026-01-01',
dateTo: '2026-06-30',
})
const summary = toSweepSummary(result, { dateFrom: '2026-01-01', dateTo: '2026-06-30' })
expect(summary.auto_linked).toBe(5)
expect(summary.unmatched).toBe(0)
expect(summary.errors).toBe(1)
expect(summary.date_from).toBe('2026-01-01')
})
it('honors an explicit confidenceThreshold override', async () => {
const supabase = makeSupabase({ data: [CA_1930], error: null })
runReconciliationMock.mockResolvedValue(emptyRunResult())
await runUnattendedReconciliationSweep(supabase as never, 'company-1', 'user-1', {
confidenceThreshold: 0.95,
})
expect(runReconciliationMock).toHaveBeenCalledWith(
supabase,
'company-1',
'user-1',
expect.objectContaining({ confidenceThreshold: 0.95 }),
)
})
})
+102 -5
View File
@@ -67,6 +67,19 @@ export interface ReconciliationRunResult {
* for human review. Always 0 on dry runs and when no threshold was given.
*/
skippedBelowThreshold: number
/**
* Below-threshold matches persisted as suggestions on the transaction
* (potential_journal_entry_id + method + confidence) for the review surface.
* Always 0 unless persistSuggestions was set on a non-dry apply run with a
* confidence threshold.
*/
suggested: number
/**
* Unmatched, non-ignored transactions the run considered (the candidate pool
* on the bank side). Lets callers report "X av Y matchade" without a second
* count query.
*/
candidates: number
}
/**
@@ -198,6 +211,18 @@ export interface ReconciliationOptions {
* committed without human review.
*/
confidenceThreshold?: number
/**
* Persist below-threshold matches (the 0.75-0.89 band: auto_fuzzy,
* auto_date_range) onto the transaction's potential_journal_entry_id /
* potential_match_method / potential_match_confidence columns instead of
* dropping them, so the review surface can offer them for confirmation.
* Only meaningful together with confidenceThreshold on a non-dry apply run;
* ignored otherwise. Suggestions are soft data: the same optimistic
* `.is('journal_entry_id', null)` guard as the apply path, plus DB triggers
* that clear them when the row is booked/ignored or the entry is consumed
* or reversed.
*/
persistSuggestions?: boolean
}
/**
@@ -417,6 +442,7 @@ export async function runReconciliation(
includeUnassigned = true,
applyOnly,
confidenceThreshold,
persistSuggestions = false,
} = options
// Fetch unlinked GL lines via RPC
@@ -451,14 +477,28 @@ export async function runReconciliation(
})
if (transactions.length === 0 || glLines.length === 0) {
return { matches: [], applied: 0, errors: 0, skippedBelowThreshold: 0 }
return {
matches: [],
applied: 0,
errors: 0,
skippedBelowThreshold: 0,
suggested: 0,
candidates: transactions.length,
}
}
// Run greedy matching, highest confidence first
let matches = greedyMatch(transactions, glLines, currency)
if (dryRun) {
return { matches, applied: 0, errors: 0, skippedBelowThreshold: 0 }
return {
matches,
applied: 0,
errors: 0,
skippedBelowThreshold: 0,
suggested: 0,
candidates: transactions.length,
}
}
// When the caller reviewed a dry-run and ticked a subset, apply ONLY pairs
@@ -477,12 +517,13 @@ export async function runReconciliation(
// counted separately. This is the server-side guardrail for unattended
// callers (nightly sync / cron), where nobody reviews a dry-run first.
let toApply = matches
let skippedBelowThreshold = 0
let belowThresholdMatches: ReconciliationMatch[] = []
if (confidenceThreshold !== undefined) {
const floor = Math.max(0, Math.min(1, confidenceThreshold))
toApply = matches.filter((m) => m.confidence >= floor)
skippedBelowThreshold = matches.length - toApply.length
belowThresholdMatches = matches.filter((m) => m.confidence < floor)
}
const skippedBelowThreshold = belowThresholdMatches.length
// Apply matches
let applied = 0
@@ -511,6 +552,18 @@ export async function runReconciliation(
errors++
} else {
applied++
// Behandlingshistorik (BFNAR 2013:2 kap 8, BFL 7:1): every auto-applied
// link is a match event and must land in the append-only log, exactly
// like the invoice-match and confirm-suggestion paths. The bus event
// below goes to event_log (30-day TTL) and is NOT an audit record.
await logMatchEvent(supabase, userId, match.transaction.id, 'matched', {
matchConfidence: match.confidence,
matchMethod: match.method,
newState: {
journal_entry_id: match.glLine.journal_entry_id,
reconciliation_method: match.method,
},
})
try {
eventBus.emit({
type: 'transaction.reconciled',
@@ -531,7 +584,51 @@ export async function runReconciliation(
}
}
return { matches, applied, errors, skippedBelowThreshold }
// Persist the below-threshold band as reviewable suggestions instead of
// dropping it. Same optimistic-lock guard as the apply loop: a row that got
// booked or linked between the read and this write matches zero rows, and
// the DB trigger clears any suggestion the moment a link lands, so a stale
// suggestion can never shadow a real link.
let suggested = 0
if (persistSuggestions) {
for (const match of belowThresholdMatches) {
try {
const { data: suggestedRows, error } = await supabase
.from('transactions')
.update({
potential_journal_entry_id: match.glLine.journal_entry_id,
potential_match_method: match.method,
potential_match_confidence: match.confidence,
})
.eq('id', match.transaction.id)
.eq('company_id', companyId)
.is('journal_entry_id', null)
.select('id')
if (!error && suggestedRows && suggestedRows.length > 0) {
suggested++
// Awaited: an unawaited promise can be frozen on serverless when the
// response returns, silently dropping the audit row.
await logMatchEvent(supabase, userId, match.transaction.id, 'auto_suggested', {
matchConfidence: match.confidence,
matchMethod: match.method,
newState: { potential_journal_entry_id: match.glLine.journal_entry_id },
})
}
} catch {
// Suggestions are best-effort: never fail the run over one row.
}
}
}
return {
matches,
applied,
errors,
skippedBelowThreshold,
suggested,
candidates: transactions.length,
}
}
// ============================================================
+270
View File
@@ -0,0 +1,270 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { manualLink, ledgerLineAmountIn } from './bank-reconciliation'
import { logMatchEvent } from '@/lib/invoices/match-log'
/**
* Confirm / reject persisted journal-entry match suggestions
* (transactions.potential_journal_entry_id, written by the reconciliation
* sweep's 0.75-0.89 band).
*
* Confirming is a reconciliation LINK, never a booking. Revalidated per pair
* at click time, in this order: row still exists and is unlinked, suggestion
* still present, suggested verifikat not already settled by another
* transaction, the voucher's net movement on the settlement account still
* agrees with the transaction's amount and direction (a suggestion computed
* before an inline rattelse re-priced the bank leg must die here, not link),
* and finally manualLink's own checks (entry posted, line on the settlement
* account, optimistic lock on the row). Stale pairs are skipped and reported,
* never failing the batch.
*
* The consumption check is read-then-act per request; two CONCURRENT requests
* confirming different rows against the same verifikat can both pass it. The
* sibling-clear trigger closes the window after the first commit, and a
* double-settled voucher still surfaces as a non-zero difference on the
* Bankavstamning status card, but within a single request the sequential
* re-fetch is the real guarantee.
*/
export type SuggestionSkipReason =
| 'not_found'
| 'no_suggestion'
| 'already_linked'
| 'voucher_consumed'
| 'amount_mismatch'
| 'link_failed'
export interface SuggestionActionResult {
confirmed: string[]
rejected: string[]
skipped: Array<{ transactionId: string; reason: SuggestionSkipReason; message?: string }>
}
interface SuggestionRow {
id: string
amount: number | string | null
currency: string | null
journal_entry_id: string | null
potential_journal_entry_id: string | null
potential_match_method: string | null
potential_match_confidence: number | string | null
cash_account_id: string | null
}
async function fetchSuggestionRow(
supabase: SupabaseClient,
companyId: string,
transactionId: string,
): Promise<SuggestionRow | null> {
const { data } = await supabase
.from('transactions')
.select(
'id, amount, currency, journal_entry_id, potential_journal_entry_id, potential_match_method, potential_match_confidence, cash_account_id',
)
.eq('id', transactionId)
.eq('company_id', companyId)
.maybeSingle()
return (data as SuggestionRow | null) ?? null
}
/**
* Resolve the settlement account manualLink must validate the voucher line
* against. Rows with a cash_account_id use that account's ledger_account. Rows
* WITHOUT one were swept under the PRIMARY cash account's scope
* (includeUnassigned), so confirmation must resolve the same way: hard-coding
* '1930' made every unassigned-row suggestion unconfirmable in a company whose
* primary account is e.g. 1920 Plusgiro. '1930' remains only the final
* fallback for companies with no cash_accounts rows at all.
*/
async function resolveSettlementAccount(
supabase: SupabaseClient,
companyId: string,
cashAccountId: string | null,
): Promise<string> {
const query = supabase.from('cash_accounts').select('ledger_account').eq('company_id', companyId)
const { data } = cashAccountId
? await query.eq('id', cashAccountId).maybeSingle()
: await query.eq('is_primary', true).maybeSingle()
return (data?.ledger_account as string | undefined) ?? '1930'
}
/** Fuzzy band tolerance: the widest amount slack any persisted suggestion was
* created under (auto_fuzzy, +-0.01), plus float headroom. */
const CONFIRM_AMOUNT_TOLERANCE = 0.011
/**
* The suggested voucher's net movement on the settlement account must still
* agree with the transaction, in the transaction's own currency. Returns null
* when it does; a skip reason message when it does not or cannot be verified
* (no comparable amount = no honest link, per the determinism rule).
*/
async function verifySuggestedAmount(
supabase: SupabaseClient,
tx: SuggestionRow,
accountNumber: string,
): Promise<string | null> {
const { data: lines } = await supabase
.from('journal_entry_lines')
.select('debit_amount, credit_amount, currency, amount_in_currency')
.eq('journal_entry_id', tx.potential_journal_entry_id)
.eq('account_number', accountNumber)
if (!lines || lines.length === 0) {
return `Verifikationen saknar rad på ${accountNumber}`
}
const txCurrency = tx.currency ?? 'SEK'
let movement = 0
for (const line of lines) {
const amount = ledgerLineAmountIn(line, txCurrency)
if (amount === null) {
return 'Verifikationens belopp kan inte jämföras i transaktionens valuta'
}
movement += amount
}
const txAmount = Number(tx.amount)
if (!Number.isFinite(txAmount)) return 'Transaktionens belopp kunde inte läsas'
if (Math.abs(Math.abs(txAmount) - Math.abs(movement)) > CONFIRM_AMOUNT_TOLERANCE) {
return 'Beloppet stämmer inte längre med verifikationen'
}
if (Math.sign(txAmount) !== Math.sign(movement)) {
return 'Riktningen stämmer inte längre med verifikationen'
}
return null
}
export async function confirmJournalEntrySuggestions(
supabase: SupabaseClient,
companyId: string,
userId: string,
transactionIds: string[],
): Promise<SuggestionActionResult> {
const result: SuggestionActionResult = { confirmed: [], rejected: [], skipped: [] }
// Sequential on purpose: each pair is re-fetched at its turn, so when two
// batch rows suggest the SAME verifikat the first confirm consumes it and the
// second reads its (trigger-cleared) suggestion as gone instead of racing.
for (const transactionId of transactionIds) {
const tx = await fetchSuggestionRow(supabase, companyId, transactionId)
if (!tx) {
result.skipped.push({ transactionId, reason: 'not_found' })
continue
}
if (tx.journal_entry_id) {
result.skipped.push({ transactionId, reason: 'already_linked' })
continue
}
if (!tx.potential_journal_entry_id) {
result.skipped.push({ transactionId, reason: 'no_suggestion' })
continue
}
// Explicit consumption check on top of the invalidation trigger: a
// verifikat another transaction already settles is not offered twice.
// (manualLink deliberately allows N:1 for the manual instalments case;
// bulk-confirming a suggestion is not that case.)
const { data: consumers } = await supabase
.from('transactions')
.select('id')
.eq('company_id', companyId)
.eq('journal_entry_id', tx.potential_journal_entry_id)
.limit(1)
if (consumers && consumers.length > 0) {
result.skipped.push({ transactionId, reason: 'voucher_consumed' })
continue
}
const accountNumber = await resolveSettlementAccount(supabase, companyId, tx.cash_account_id)
// Amount/direction revalidation: a suggestion is a snapshot, and the
// voucher's bank leg can legally change after it was computed (inline
// rattelse strike-and-replace keeps status 'posted', so no invalidation
// trigger fires). Never link on a stale snapshot.
const amountProblem = await verifySuggestedAmount(supabase, tx, accountNumber)
if (amountProblem) {
result.skipped.push({ transactionId, reason: 'amount_mismatch', message: amountProblem })
continue
}
const linkResult = await manualLink(
supabase,
companyId,
transactionId,
tx.potential_journal_entry_id,
userId,
accountNumber,
)
if (!linkResult.success) {
result.skipped.push({
transactionId,
reason: 'link_failed',
message: linkResult.error,
})
continue
}
result.confirmed.push(transactionId)
// Awaited: on serverless an unawaited promise can be frozen when the
// response returns, silently dropping the audit row. logMatchEvent itself
// never throws.
await logMatchEvent(supabase, userId, transactionId, 'linked_to_existing_voucher', {
matchMethod: tx.potential_match_method ?? undefined,
matchConfidence:
tx.potential_match_confidence !== null
? Number(tx.potential_match_confidence)
: undefined,
newState: {
journal_entry_id: tx.potential_journal_entry_id,
reconciliation_method: 'manual',
confirmed_suggestion: true,
},
})
}
return result
}
export async function rejectJournalEntrySuggestions(
supabase: SupabaseClient,
companyId: string,
userId: string,
transactionIds: string[],
): Promise<SuggestionActionResult> {
const result: SuggestionActionResult = { confirmed: [], rejected: [], skipped: [] }
for (const transactionId of transactionIds) {
const tx = await fetchSuggestionRow(supabase, companyId, transactionId)
if (!tx) {
result.skipped.push({ transactionId, reason: 'not_found' })
continue
}
if (!tx.potential_journal_entry_id) {
result.skipped.push({ transactionId, reason: 'no_suggestion' })
continue
}
const { error } = await supabase
.from('transactions')
.update({
potential_journal_entry_id: null,
potential_match_method: null,
potential_match_confidence: null,
})
.eq('id', transactionId)
.eq('company_id', companyId)
if (error) {
result.skipped.push({ transactionId, reason: 'link_failed', message: error.message })
continue
}
result.rejected.push(transactionId)
await logMatchEvent(supabase, userId, transactionId, 'suggestion_cleared', {
previousState: {
potential_journal_entry_id: tx.potential_journal_entry_id,
potential_match_method: tx.potential_match_method,
potential_match_confidence: tx.potential_match_confidence,
},
})
}
return result
}
+235
View File
@@ -0,0 +1,235 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import {
runReconciliation,
DEFAULT_UNATTENDED_CONFIDENCE_THRESHOLD,
type ReconciliationOptions,
} from './bank-reconciliation'
import { createLogger } from '@/lib/logger'
const log = createLogger('reconciliation.unattended-sweep')
/** Per-account outcome of one unattended sweep. */
export interface SweepAccountResult {
/** null on the legacy fallback run for companies with no cash_accounts rows. */
cashAccountId: string | null
accountNumber: string
currency: string
/** Matches auto-linked at or above the unattended confidence floor. */
applied: number
/** Apply failures (optimistic-lock conflicts, DB errors) plus a whole-account
* run failure, which counts as 1 without aborting the other accounts. */
errors: number
/** Matches proposed below the floor: candidate suggestions for human review. */
skippedBelowThreshold: number
/** Below-floor matches persisted onto potential_journal_entry_id. */
suggested: number
/** Unmatched transactions the run considered on this account. */
candidates: number
/** Total matches the matcher proposed for this account. */
proposed: number
}
export interface UnattendedSweepResult {
accounts: SweepAccountResult[]
applied: number
errors: number
skippedBelowThreshold: number
suggested: number
/** Candidate transactions the sweep left neither linked nor suggested. */
unmatched: number
}
export interface UnattendedSweepOptions {
dateFrom?: string
dateTo?: string
/**
* Confidence floor for auto-apply. Defaults to
* DEFAULT_UNATTENDED_CONFIDENCE_THRESHOLD: these sweeps run with nobody
* reviewing a dry-run first, so fuzzy / date-range matches must never be
* committed automatically.
*/
confidenceThreshold?: number
/**
* Persist the below-floor band as reviewable suggestions (default true:
* every unattended caller feeds the "Granska migrerad historik" surface).
*/
persistSuggestions?: boolean
}
/**
* The JSONB stamped on bank_connections.last_sie_sweep /
* bank_file_imports.sie_sweep so the UI can render the sweep outcome without
* recomputing. snake_case: it lives in the DB and crosses the API boundary.
*/
export interface SieSweepSummary {
auto_linked: number
suggested: number
unmatched: number
/**
* Apply/run failures across the sweep. NOT decoration: a whole-account run
* that threw contributes 0 candidates, so its transactions are absent from
* `unmatched` too. errors > 0 means the other three numbers describe an
* INCOMPLETE sweep, and any UI reading this summary must not present it as
* "all done".
*/
errors: number
date_from: string | null
date_to: string | null
ran_at: string
}
export function toSweepSummary(
result: UnattendedSweepResult,
options: { dateFrom?: string; dateTo?: string } = {},
): SieSweepSummary {
return {
auto_linked: result.applied,
suggested: result.suggested,
unmatched: result.unmatched,
errors: result.errors,
date_from: options.dateFrom ?? null,
date_to: options.dateTo ?? null,
ran_at: new Date().toISOString(),
}
}
/**
* Run the unattended post-sync reconciliation sweep once per cash account
* instead of once per company (issue #1298).
*
* The pooled form (`runReconciliation` with no cashAccountId) filtered the
* transaction side by currency alone while the GL side stayed on '1930', so a
* company with two same-currency accounts (checking 1930 + savings 1931) could
* auto-link a savings transaction to an unlinked 1930 voucher and persist a
* wrong journal_entry_id. Only a log warning guarded it
* (warnIfUnscopedAcrossCashAccounts). Here every enabled cash account gets its
* own scoped run: its BAS code on the GL side, its cash_account_id on the
* transaction side, and NULL-cash_account_id rows claimed only by the primary
* account (same rule as Bankavstamning).
*
* Companies with no cash_accounts rows at all keep the legacy single
* 1930/SEK run: with no rows there is no per-account scope to apply, and
* scopeTransactionsToAccount's currency-only path is the supported mode there.
*
* One account's run failing (thrown) is counted as one error on that account
* and the sweep continues: an unattended sweep must not let one broken account
* block matching on the others. The initial cash_accounts lookup failing throws
* instead: silently degrading to the pooled run would re-create exactly the
* cross-linking this helper exists to remove (same fail-closed contract as
* resolveCashAccountScope).
*/
export async function runUnattendedReconciliationSweep(
supabase: SupabaseClient,
companyId: string,
userId: string,
options: UnattendedSweepOptions = {},
): Promise<UnattendedSweepResult> {
const { dateFrom, dateTo, persistSuggestions = true } = options
const confidenceThreshold =
options.confidenceThreshold ?? DEFAULT_UNATTENDED_CONFIDENCE_THRESHOLD
const { data: cashAccounts, error } = await supabase
.from('cash_accounts')
.select('id, ledger_account, currency, is_primary')
.eq('company_id', companyId)
.eq('enabled', true)
.order('ledger_account')
if (error) {
throw new Error('Kunde inte hämta kassakonton för avstämningssvepet')
}
type Scope = {
cashAccountId: string | null
accountNumber: string
currency: string
includeUnassigned: boolean
}
const rows = (cashAccounts ?? []) as Array<{
id: string
ledger_account: string
currency: string | null
is_primary: boolean | null
}>
const scopes: Scope[] =
rows.length > 0
? rows.map((row) => ({
cashAccountId: row.id,
accountNumber: row.ledger_account,
currency: row.currency ?? 'SEK',
includeUnassigned: Boolean(row.is_primary),
}))
: [
{
cashAccountId: null,
accountNumber: '1930',
currency: 'SEK',
includeUnassigned: true,
},
]
const accounts: SweepAccountResult[] = []
for (const scope of scopes) {
const runOptions: ReconciliationOptions = {
dateFrom,
dateTo,
accountNumber: scope.accountNumber,
currency: scope.currency,
cashAccountId: scope.cashAccountId ?? undefined,
includeUnassigned: scope.includeUnassigned,
confidenceThreshold,
persistSuggestions,
}
try {
const result = await runReconciliation(supabase, companyId, userId, runOptions)
accounts.push({
cashAccountId: scope.cashAccountId,
accountNumber: scope.accountNumber,
currency: scope.currency,
applied: result.applied,
errors: result.errors,
skippedBelowThreshold: result.skippedBelowThreshold,
suggested: result.suggested,
candidates: result.candidates,
proposed: result.matches.length,
})
} catch (err) {
log.warn('per-account sweep run failed; continuing with remaining accounts', {
companyId,
entityType: 'cash_account',
details: {
accountNumber: scope.accountNumber,
cashAccountId: scope.cashAccountId,
message: err instanceof Error ? err.message : String(err),
},
})
accounts.push({
cashAccountId: scope.cashAccountId,
accountNumber: scope.accountNumber,
currency: scope.currency,
applied: 0,
errors: 1,
skippedBelowThreshold: 0,
suggested: 0,
candidates: 0,
proposed: 0,
})
}
}
const applied = accounts.reduce((sum, a) => sum + a.applied, 0)
const suggested = accounts.reduce((sum, a) => sum + a.suggested, 0)
const candidates = accounts.reduce((sum, a) => sum + a.candidates, 0)
return {
accounts,
applied,
errors: accounts.reduce((sum, a) => sum + a.errors, 0),
skippedBelowThreshold: accounts.reduce((sum, a) => sum + a.skippedBelowThreshold, 0),
suggested,
unmatched: Math.max(0, candidates - applied - suggested),
}
}
+36
View File
@@ -1451,6 +1451,7 @@
"step_books_sie": "+ SIE",
"step_bank_title": "Connect the bank",
"step_bank_description": "Fetch transactions automatically, or import a bank statement.",
"step_bank_sweep_note": "{matched, plural, one {# matched against the import} other {# matched against the import}}, {toReview, plural, one {# to review} other {# to review}}",
"step_bank_action": "Connect the bank",
"step_assistant_title": "Build your bookkeeping assistant",
"step_assistant_beta": "Beta",
@@ -2702,6 +2703,7 @@
"delete_aria": "Delete transaction",
"edit_title_aria": "Edit title",
"edited_badge": "edited",
"pre_migration_marker": "from the period before your migration",
"original_name_tooltip": "Original bank name: {name}",
"edit_title_dialog_title": "Edit transaction title",
"edit_title_warning": "Are you sure you want to edit the title of this transaction?",
@@ -3291,6 +3293,23 @@
"saving": "Saving...",
"save": "Save transaction"
},
"tx_review": {
"intro": "{count, plural, one {# bank transaction matches a voucher in your imported bookkeeping. Confirm the link and nothing gets booked twice.} other {# bank transactions match vouchers in your imported bookkeeping. Confirm the links and nothing gets booked twice.}}",
"rerun": "Run matching again",
"confirm_all": "Confirm all ({count})",
"empty_title": "No suggestions to review",
"empty_description": "All match suggestions are handled. Remaining transactions are under To book.",
"th_date": "Date",
"th_description": "Description",
"th_amount": "Amount",
"th_suggestion": "Suggested voucher",
"th_actions": "Actions",
"suggestion_gone": "The suggestion is no longer valid",
"confirm": "Confirm",
"row_menu": "More actions",
"open_match_dialog": "Pick another voucher…",
"reject": "Reject the suggestion"
},
"tx_history": {
"search_placeholder": "Search transactions...",
"filter_all": "All",
@@ -5463,6 +5482,17 @@
"skv_err_commit_failed": "draft created, not posted",
"skv_err_other": "failed",
"mode_all": "All",
"mode_review": "Review suggestions",
"review_attn_body": "{count, plural, one {# historical bank transaction matches} other {# historical bank transactions match}} your imported bookkeeping.",
"review_attn_cta": "Review the suggestions",
"review_confirm_failed": "Could not link the suggestions",
"review_confirm_done_title": "{count, plural, one {# transaction linked} other {# transactions linked}}",
"review_confirm_done_description": "Linked to existing vouchers. No new bookkeeping was created.",
"review_confirm_done_skipped": "{count, plural, one {# was skipped} other {# were skipped}}: the suggestion had changed since it was created.",
"review_reject_failed": "Could not reject the suggestion",
"review_rerun_failed": "Matching could not run",
"review_rerun_done_title": "Matching finished",
"review_rerun_done_description": "{applied, plural, one {# transaction was linked automatically} other {# transactions were linked automatically}}, {suggested, plural, one {# new suggestion to review} other {# new suggestions to review}}.",
"footer_to_handle": "{count, plural, =0 {Nothing to handle} =1 {1 to handle} other {# to handle}}",
"recon_attn": "{count, plural, one {1 unbooked bank transaction: the bank reconciliation can find automatic matches against existing vouchers.} other {# unbooked bank transactions: the bank reconciliation can find automatic matches against existing vouchers.}}",
"recon_attn_action": "Preview matches"
@@ -6888,7 +6918,13 @@
"reveal_skipped": "{count, plural, one {# voucher was skipped, see details below.} other {# vouchers were skipped, see details below.}}",
"reveal_bridge": "The history is in place. What's missing is the present: the bank.",
"reveal_cta_bank": "Connect the bank",
"reveal_cta_csv": "Upload bank statement (CSV)",
"reveal_cta_open": "Open Accounted",
"next_steps_title": "Next step: fetch your bank history",
"next_steps_match_copy": "Connect your bank or upload bank statements, and we match the bank history against what you just imported.",
"next_steps_cta_bank": "Connect your bank",
"next_steps_cta_csv": "Upload bank statement (CSV)",
"next_steps_window_hint": "The bank connection fetches recent history (most banks stop around 90 days). For older periods, upload bank statements as CSV, matching works the same way.",
"vat_review_title": "{count, plural, one {# account has no VAT treatment} other {# accounts have no VAT treatment}}",
"vat_review_description": "Set VAT treatments on imported revenue and purchase accounts so the VAT return uses the correct boxes.",
"vat_review_action": "Review VAT treatments in the chart",
+36
View File
@@ -1451,6 +1451,7 @@
"step_books_sie": "+ SIE",
"step_bank_title": "Koppla banken",
"step_bank_description": "Hämta transaktioner automatiskt, eller importera ett kontoutdrag.",
"step_bank_sweep_note": "{matched, plural, one {# matchad mot importen} other {# matchade mot importen}}, {toReview, plural, one {# att granska} other {# att granska}}",
"step_bank_action": "Koppla banken",
"step_assistant_title": "Bygg din bokföringsassistent",
"step_assistant_beta": "Beta",
@@ -2702,6 +2703,7 @@
"delete_aria": "Ta bort transaktion",
"edit_title_aria": "Ändra titel",
"edited_badge": "redigerad",
"pre_migration_marker": "från perioden före din migrering",
"original_name_tooltip": "Bankens originalnamn: {name}",
"edit_title_dialog_title": "Ändra transaktionens titel",
"edit_title_warning": "Är du säker på att du vill ändra titeln på den här transaktionen?",
@@ -3291,6 +3293,23 @@
"saving": "Sparar...",
"save": "Spara transaktion"
},
"tx_review": {
"intro": "{count, plural, one {# banktransaktion matchar ett verifikat i din importerade bokföring. Bekräfta kopplingen så bokförs ingenting dubbelt.} other {# banktransaktioner matchar verifikat i din importerade bokföring. Bekräfta kopplingarna så bokförs ingenting dubbelt.}}",
"rerun": "Kör matchning igen",
"confirm_all": "Bekräfta alla ({count})",
"empty_title": "Inga förslag att granska",
"empty_description": "Alla matchförslag är hanterade. Kvarvarande transaktioner hittar du under Att bokföra.",
"th_date": "Datum",
"th_description": "Beskrivning",
"th_amount": "Belopp",
"th_suggestion": "Föreslaget verifikat",
"th_actions": "Åtgärder",
"suggestion_gone": "Förslaget är inte längre giltigt",
"confirm": "Bekräfta",
"row_menu": "Fler åtgärder",
"open_match_dialog": "Välj annat verifikat…",
"reject": "Avvisa förslaget"
},
"tx_history": {
"search_placeholder": "Sök transaktioner...",
"filter_all": "Alla",
@@ -5463,6 +5482,17 @@
"skv_err_commit_failed": "utkast skapat, ej bokfört",
"skv_err_other": "misslyckades",
"mode_all": "Alla",
"mode_review": "Granska förslag",
"review_attn_body": "{count, plural, one {# historisk banktransaktion matchar} other {# historiska banktransaktioner matchar}} din importerade bokföring.",
"review_attn_cta": "Granska förslagen",
"review_confirm_failed": "Kunde inte koppla förslagen",
"review_confirm_done_title": "{count, plural, one {# transaktion kopplad} other {# transaktioner kopplade}}",
"review_confirm_done_description": "Kopplade till befintliga verifikat. Ingen ny bokföring skapades.",
"review_confirm_done_skipped": "{count, plural, one {# hoppades över} other {# hoppades över}}: förslaget hade ändrats sedan det skapades.",
"review_reject_failed": "Kunde inte avvisa förslaget",
"review_rerun_failed": "Matchningen kunde inte köras",
"review_rerun_done_title": "Matchning klar",
"review_rerun_done_description": "{applied, plural, one {# transaktion kopplades automatiskt} other {# transaktioner kopplades automatiskt}}, {suggested, plural, one {# nytt förslag att granska} other {# nya förslag att granska}}.",
"footer_to_handle": "{count, plural, =0 {Inget att hantera} =1 {1 att hantera} other {# att hantera}}",
"recon_attn": "{count, plural, one {1 obokförd banktransaktion: bankavstämningen kan hitta automatiska träffar mot befintliga verifikationer.} other {# obokförda banktransaktioner: bankavstämningen kan hitta automatiska träffar mot befintliga verifikationer.}}",
"recon_attn_action": "Förhandsgranska träffar"
@@ -6888,7 +6918,13 @@
"reveal_skipped": "{count, plural, one {# verifikat hoppades över, se detaljer nedan.} other {# verifikat hoppades över, se detaljer nedan.}}",
"reveal_bridge": "Historiken är på plats. Det som saknas är nuet: banken.",
"reveal_cta_bank": "Koppla banken",
"reveal_cta_csv": "Ladda upp kontoutdrag (CSV)",
"reveal_cta_open": "Öppna Accounted",
"next_steps_title": "Nästa steg: hämta bankhistoriken",
"next_steps_match_copy": "Koppla din bank eller ladda upp kontoutdrag, så matchar vi bankhistoriken mot det du just importerade.",
"next_steps_cta_bank": "Koppla din bank",
"next_steps_cta_csv": "Ladda upp kontoutdrag (CSV)",
"next_steps_window_hint": "Bankkopplingen hämtar den senaste tidens historik (de flesta banker stannar vid ca 90 dagar). För äldre perioder laddar du upp kontoutdrag som CSV, matchningen fungerar likadant.",
"vat_review_title": "{count, plural, one {# konto saknar momskod} other {# konton saknar momskod}}",
"vat_review_description": "Sätt momskod på importerade intäkts- och inköpskonton så att momsdeklarationen hamnar i rätt ruta.",
"vat_review_action": "Granska momskoder i kontoplanen",
@@ -0,0 +1,148 @@
-- Migration: persisted journal-entry match suggestions on transactions
--
-- The 4-pass bank matcher proposes matches in a 0.75-0.89 confidence band
-- (auto_fuzzy, auto_date_range) that unattended sweeps must never auto-apply.
-- Until now those proposals were computed and then dropped: the migrator who
-- imported a Fortnox SIE file and connected their bank never saw them. These
-- columns persist the band as reviewable suggestions, mirroring
-- potential_invoice_id / potential_supplier_invoice_id (one candidate per row).
--
-- Suggestions are soft data: nullable columns, no journal writes, fully
-- reversible. Confirming one goes through the ordinary manual-link path
-- (server-side revalidation at click time); rejecting clears the columns.
ALTER TABLE public.transactions
ADD COLUMN potential_journal_entry_id UUID
REFERENCES public.journal_entries (id) ON DELETE SET NULL,
ADD COLUMN potential_match_method TEXT,
ADD COLUMN potential_match_confidence NUMERIC(3,2);
-- A suggestion is all-or-nothing (id + method + confidence together), and a
-- transaction that is already linked to a journal entry carries no suggestion.
-- The BEFORE-UPDATE trigger below clears the trio whenever a link lands or the
-- row is ignored, so ordinary writers never trip this. ON DELETE SET NULL on
-- the FK nulls only the id; the trigger function also re-nulls method and
-- confidence on any write, and the CHECK is written to tolerate the transient
-- id-only-null state a FK SET NULL leaves behind (method/confidence dangling
-- without an id is inert for every reader, which keys on the id).
ALTER TABLE public.transactions
ADD CONSTRAINT transactions_potential_je_check CHECK (
potential_journal_entry_id IS NULL
OR (
potential_match_method IS NOT NULL
AND potential_match_confidence IS NOT NULL
AND journal_entry_id IS NULL
)
);
-- The review surface asks "does this company have suggestions?" and lists them;
-- suggestion rows are a small fraction of transactions.
CREATE INDEX idx_transactions_potential_je
ON public.transactions (company_id)
WHERE potential_journal_entry_id IS NOT NULL;
-- ============================================================
-- Suggestion invalidation, transaction side
-- ============================================================
-- Clear the suggestion whenever the transaction stops being an open row:
-- booked or linked (journal_entry_id set, by ANY path: categorization,
-- reconciliation, MCP, RPCs) or explicitly ignored. BEFORE trigger so the
-- cleared values land in the same write and the CHECK above always sees a
-- consistent row.
CREATE OR REPLACE FUNCTION public.clear_potential_journal_entry_on_close()
RETURNS TRIGGER
LANGUAGE plpgsql
SET search_path = public
AS $$
BEGIN
IF NEW.journal_entry_id IS NOT NULL OR NEW.is_ignored IS TRUE THEN
NEW.potential_journal_entry_id := NULL;
NEW.potential_match_method := NULL;
NEW.potential_match_confidence := NULL;
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER transactions_clear_potential_je
BEFORE INSERT OR UPDATE ON public.transactions
FOR EACH ROW
EXECUTE FUNCTION public.clear_potential_journal_entry_on_close();
-- ============================================================
-- Suggestion invalidation, journal-entry side
-- ============================================================
-- (a) The suggested entry is consumed by another transaction's link: other
-- rows still suggesting it would double-consume the verifikat on bulk
-- confirm, so their suggestions are cleared as soon as any link lands.
-- SECURITY DEFINER: the writer linking their own transaction may not have
-- UPDATE visibility over sibling rows under RLS, but the scope is derived
-- entirely from the row being written (company_id + journal_entry_id),
-- never from caller input.
CREATE OR REPLACE FUNCTION public.clear_sibling_je_suggestions_on_link()
RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
UPDATE public.transactions
SET potential_journal_entry_id = NULL,
potential_match_method = NULL,
potential_match_confidence = NULL
WHERE company_id = NEW.company_id
AND potential_journal_entry_id = NEW.journal_entry_id
AND id <> NEW.id;
RETURN NULL;
END;
$$;
CREATE TRIGGER transactions_clear_sibling_je_suggestions
AFTER UPDATE OF journal_entry_id ON public.transactions
FOR EACH ROW
WHEN (NEW.journal_entry_id IS NOT NULL
AND NEW.journal_entry_id IS DISTINCT FROM OLD.journal_entry_id)
EXECUTE FUNCTION public.clear_sibling_je_suggestions_on_link();
-- (b) The suggested entry is reversed via storno: it is no longer a live
-- verifikat to settle against, so pending suggestions pointing at it are
-- stale. Same SECURITY DEFINER rationale as above.
CREATE OR REPLACE FUNCTION public.clear_je_suggestions_on_reversal()
RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
UPDATE public.transactions
SET potential_journal_entry_id = NULL,
potential_match_method = NULL,
potential_match_confidence = NULL
WHERE company_id = NEW.company_id
AND potential_journal_entry_id = NEW.id;
RETURN NULL;
END;
$$;
CREATE TRIGGER journal_entries_clear_je_suggestions_on_reversal
AFTER UPDATE OF status ON public.journal_entries
FOR EACH ROW
WHEN (NEW.status = 'reversed' AND OLD.status IS DISTINCT FROM 'reversed')
EXECUTE FUNCTION public.clear_je_suggestions_on_reversal();
-- ============================================================
-- Sweep summaries
-- ============================================================
-- One JSONB per surface so the UI can render "Vi matchade X av Y" without
-- recomputing: {auto_linked, suggested, unmatched, date_from, date_to, ran_at}.
ALTER TABLE public.bank_connections
ADD COLUMN last_sie_sweep JSONB;
ALTER TABLE public.bank_file_imports
ADD COLUMN sie_sweep JSONB;
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,40 @@
-- Migration: widen payment_match_log.action CHECK with 'linked_to_existing_voucher'
--
-- The code has emitted action = 'linked_to_existing_voucher' since the
-- existing-voucher link paths shipped (lib/transactions/link-journal-entry.ts,
-- lib/reconciliation/bank-reconciliation.ts / autoReconcileTransactionForLinkedVoucher),
-- but the CHECK from 20260323120000 never included the value. logMatchEvent is
-- fire-and-forget, so every such insert failed the constraint silently and the
-- link went unlogged: a BFL 7:1 audit-trail gap, since match/unmatch events are
-- rakenskapsinformation. This restores logging for existing-voucher links.
--
-- Backfill of the silently dropped rows is NOT possible (the inserts never
-- landed anywhere); the transactions themselves still carry the link via
-- journal_entry_id + reconciliation_method. A one-off count of affected links
-- since 2026-03-23 can be estimated on prod from transactions rows with
-- reconciliation_method = 'manual' joined against the absence of a matching
-- payment_match_log row; left as an ops follow-up, not a migration concern.
-- NOT VALID + VALIDATE: the plain ADD CONSTRAINT would scan the table under
-- an ACCESS EXCLUSIVE lock. NOT VALID makes both ALTERs brief metadata-only
-- locks, and VALIDATE scans under SHARE UPDATE EXCLUSIVE, which does not
-- block concurrent match-log inserts. The new set is a strict superset of the
-- old CHECK, so validation cannot fail on existing rows.
ALTER TABLE public.payment_match_log
DROP CONSTRAINT payment_match_log_action_check;
ALTER TABLE public.payment_match_log
ADD CONSTRAINT payment_match_log_action_check CHECK (action IN (
'matched',
'unmatched',
'auto_suggested',
'suggestion_cleared',
'storno_conflict_resolved',
'linked_to_existing_voucher'
)) NOT VALID;
ALTER TABLE public.payment_match_log
VALIDATE CONSTRAINT payment_match_log_action_check;
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest'
import { seedCompany, insertTransaction } from '@/tests/pg/fixtures'
import { getPool } from '@/tests/pg/setup'
/**
* pg-real coverage for the payment_match_log action CHECK
* (20260323120000_payment_match_log.sql +
* 20260813210000_payment_match_log_linked_to_existing_voucher.sql).
*
* Locks in Gap F: the code has emitted action = 'linked_to_existing_voucher'
* since the existing-voucher link paths shipped, but the original CHECK never
* included the value, and because logMatchEvent is fire-and-forget the
* constraint violation was swallowed: every existing-voucher link went
* unlogged. This suite asserts the widened CHECK accepts the value, so a
* future rewrite of the constraint that forgets it fails here instead of
* silently reopening the audit-trail gap.
*/
async function insertLogRow(params: {
userId: string
transactionId: string
action: string
}): Promise<void> {
await getPool().query(
`INSERT INTO public.payment_match_log (user_id, transaction_id, action)
VALUES ($1, $2, $3)`,
[params.userId, params.transactionId, params.action],
)
}
describe('payment_match_log.action CHECK', () => {
it('accepts every action the code emits, including linked_to_existing_voucher', async () => {
const { userId, companyId } = await seedCompany()
const txId = await insertTransaction({ companyId, userId })
// The full MatchAction union from lib/invoices/match-log.ts. If a new
// action is added there, this list (and the CHECK) must grow with it.
const actions = [
'matched',
'unmatched',
'auto_suggested',
'suggestion_cleared',
'storno_conflict_resolved',
'linked_to_existing_voucher',
]
for (const action of actions) {
await expect(insertLogRow({ userId, transactionId: txId, action })).resolves.not.toThrow()
}
const { rows } = await getPool().query(
`SELECT count(*)::int AS n FROM public.payment_match_log WHERE transaction_id = $1`,
[txId],
)
expect(rows[0].n).toBe(actions.length)
})
it('still rejects unknown actions', async () => {
const { userId, companyId } = await seedCompany()
const txId = await insertTransaction({ companyId, userId })
await expect(
insertLogRow({ userId, transactionId: txId, action: 'not_a_real_action' }),
).rejects.toThrow(/payment_match_log_action_check/)
})
})
@@ -0,0 +1,185 @@
import { describe, expect, it } from 'vitest'
import { seedCompany, insertTransaction, insertDraftJournalEntry } from '@/tests/pg/fixtures'
import { getPool } from '@/tests/pg/setup'
/**
* pg-real coverage for persisted journal-entry match suggestions
* (20260813121000_transactions_potential_journal_entry.sql).
*
* Locks in:
* - the all-or-nothing CHECK on the suggestion trio,
* - self-clear on link / ignore (BEFORE trigger),
* - sibling-clear when the suggested verifikat is consumed by another
* transaction's link (the double-consume guard bulk confirm relies on),
* - clear on storno reversal of the suggested entry,
* - the sweep-summary JSONB columns.
*/
// status: 'posted' routes to insertPostedJournalEntry, which inserts a default
// balanced 1930/3001 line pair in the same transaction: adding lines afterwards
// would trip the line-immutability trigger.
async function insertPostedEntry(params: {
userId: string
companyId: string
fiscalPeriodId: string
}): Promise<string> {
return insertDraftJournalEntry({ ...params, status: 'posted' })
}
async function setSuggestion(txId: string, jeId: string): Promise<void> {
await getPool().query(
`UPDATE public.transactions
SET potential_journal_entry_id = $2,
potential_match_method = 'auto_date_range',
potential_match_confidence = 0.85
WHERE id = $1`,
[txId, jeId],
)
}
async function getSuggestion(txId: string): Promise<{
potential_journal_entry_id: string | null
potential_match_method: string | null
potential_match_confidence: string | null
}> {
const { rows } = await getPool().query(
`SELECT potential_journal_entry_id, potential_match_method, potential_match_confidence
FROM public.transactions WHERE id = $1`,
[txId],
)
return rows[0]
}
describe('transactions potential_journal_entry: schema', () => {
it('accepts a full suggestion trio on an unlinked transaction', async () => {
const { userId, companyId, fiscalPeriodId } = await seedCompany()
const jeId = await insertPostedEntry({ userId, companyId, fiscalPeriodId })
const txId = await insertTransaction({ companyId, userId })
await setSuggestion(txId, jeId)
const row = await getSuggestion(txId)
expect(row.potential_journal_entry_id).toBe(jeId)
expect(row.potential_match_method).toBe('auto_date_range')
expect(Number(row.potential_match_confidence)).toBe(0.85)
})
it('rejects a partial trio (id without method/confidence)', async () => {
const { userId, companyId, fiscalPeriodId } = await seedCompany()
const jeId = await insertPostedEntry({ userId, companyId, fiscalPeriodId })
const txId = await insertTransaction({ companyId, userId })
await expect(
getPool().query(
`UPDATE public.transactions SET potential_journal_entry_id = $2 WHERE id = $1`,
[txId, jeId],
),
).rejects.toThrow(/transactions_potential_je_check/)
})
})
describe('transactions potential_journal_entry: self-clear trigger', () => {
it('clears the suggestion when the transaction is linked to any journal entry', async () => {
const { userId, companyId, fiscalPeriodId } = await seedCompany()
const suggestedJe = await insertPostedEntry({ userId, companyId, fiscalPeriodId })
const otherJe = await insertPostedEntry({ userId, companyId, fiscalPeriodId })
const txId = await insertTransaction({ companyId, userId })
await setSuggestion(txId, suggestedJe)
// Link the row elsewhere (any path setting journal_entry_id qualifies).
await getPool().query(
`UPDATE public.transactions SET journal_entry_id = $2 WHERE id = $1`,
[txId, otherJe],
)
const row = await getSuggestion(txId)
expect(row.potential_journal_entry_id).toBeNull()
expect(row.potential_match_method).toBeNull()
expect(row.potential_match_confidence).toBeNull()
})
it('clears the suggestion when the transaction is ignored', async () => {
const { userId, companyId, fiscalPeriodId } = await seedCompany()
const jeId = await insertPostedEntry({ userId, companyId, fiscalPeriodId })
const txId = await insertTransaction({ companyId, userId })
await setSuggestion(txId, jeId)
await getPool().query(`UPDATE public.transactions SET is_ignored = true WHERE id = $1`, [txId])
const row = await getSuggestion(txId)
expect(row.potential_journal_entry_id).toBeNull()
})
})
describe('transactions potential_journal_entry: sibling-clear on consumption', () => {
it('clears other rows suggesting a verifikat once any transaction links to it', async () => {
const { userId, companyId, fiscalPeriodId } = await seedCompany()
const jeId = await insertPostedEntry({ userId, companyId, fiscalPeriodId })
const txA = await insertTransaction({ companyId, userId, externalId: 'ext-a' })
const txB = await insertTransaction({ companyId, userId, externalId: 'ext-b' })
await setSuggestion(txA, jeId)
await setSuggestion(txB, jeId)
// txA consumes the verifikat.
await getPool().query(
`UPDATE public.transactions SET journal_entry_id = $2 WHERE id = $1`,
[txA, jeId],
)
// txB's suggestion is gone: bulk confirm can no longer double-consume.
const rowB = await getSuggestion(txB)
expect(rowB.potential_journal_entry_id).toBeNull()
expect(rowB.potential_match_method).toBeNull()
})
it('does not clear suggestions pointing at OTHER verifikat', async () => {
const { userId, companyId, fiscalPeriodId } = await seedCompany()
const je1 = await insertPostedEntry({ userId, companyId, fiscalPeriodId })
const je2 = await insertPostedEntry({ userId, companyId, fiscalPeriodId })
const txA = await insertTransaction({ companyId, userId, externalId: 'ext-a2' })
const txB = await insertTransaction({ companyId, userId, externalId: 'ext-b2' })
await setSuggestion(txA, je1)
await setSuggestion(txB, je2)
await getPool().query(
`UPDATE public.transactions SET journal_entry_id = $2 WHERE id = $1`,
[txA, je1],
)
const rowB = await getSuggestion(txB)
expect(rowB.potential_journal_entry_id).toBe(je2)
})
})
describe('transactions potential_journal_entry: clear on reversal', () => {
it('clears suggestions pointing at an entry that is reversed via storno', async () => {
const { userId, companyId, fiscalPeriodId } = await seedCompany()
const jeId = await insertPostedEntry({ userId, companyId, fiscalPeriodId })
const txId = await insertTransaction({ companyId, userId })
await setSuggestion(txId, jeId)
// The storno path's status transition (posted -> reversed).
await getPool().query(
`UPDATE public.journal_entries SET status = 'reversed' WHERE id = $1`,
[jeId],
)
const row = await getSuggestion(txId)
expect(row.potential_journal_entry_id).toBeNull()
})
})
describe('sweep summary columns', () => {
it('bank_file_imports.sie_sweep and bank_connections.last_sie_sweep exist as JSONB', async () => {
const { rows } = await getPool().query(
`SELECT table_name, column_name, data_type
FROM information_schema.columns
WHERE (table_name = 'bank_file_imports' AND column_name = 'sie_sweep')
OR (table_name = 'bank_connections' AND column_name = 'last_sie_sweep')`,
)
expect(rows).toHaveLength(2)
for (const row of rows) {
expect(row.data_type).toBe('jsonb')
}
})
})
+7
View File
@@ -625,6 +625,13 @@ export interface Transaction {
// Potential supplier invoice match (suggested, not confirmed)
potential_supplier_invoice_id: string | null
// Potential journal-entry match (suggested by the reconciliation sweep, not
// confirmed). All three set together, or all null; cleared by DB triggers
// when the row is booked/ignored or the entry is consumed/reversed.
potential_journal_entry_id?: string | null
potential_match_method?: string | null
potential_match_confidence?: number | null
// Bookkeeping
journal_entry_id: string | null
mcc_code: number | null