From b5c3c3ec045873870bf8c58d835832636776f84f Mon Sep 17 00:00:00 2001
From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com>
Date: Mon, 1 Jun 2026 21:08:06 +0200
Subject: [PATCH] fix(reconciliation): surface bank tx + match to existing
verifikat, delete UX, DPA links (#623)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix(reconciliation): surface bank transactions via robust cash_account_id scoping
The per-account reconciliation scoping silently returned zero transactions for
companies whose rows were NULL or mis-assigned mid-backfill (e.g. Arcim: 138
transactions, 101 unbooked, yet Bankavstämning showed "0 kr" while the 1930 GL
movement and a large difference still displayed). Two causes, both fixed:
- scopeTransactionsToAccount used a fragile nested or(...,and(is.null,...))
PostgREST filter. Replace it with a flat, reliable
`currency = X AND (cash_account_id = id OR cash_account_id IS NULL)` and share
the one implementation with /api/transactions so the status card and the lists
can never drift.
- The original best-effort backfill only touched NULL rows and an earlier
revision mis-assigned cash_account_id (the since-fixed min(uuid) bug), which
migrations cannot self-correct. Add an idempotent repair migration that
re-seeds the default 1930 account and re-derives cash_account_id (correcting
non-NULL mis-assignments) for booked rows and single-account companies.
Also localise manualLink's user-facing errors to Swedish. Adds unit coverage for
the new filter shape and pg-real coverage for the repair (incl. the Arcim
single-account reproduction).
Co-Authored-By: Claude Opus 4.8 (1M context)
* feat(transactions): match a bank transaction to an existing verifikat
Adds a "Matcha mot befintlig verifikation" action to the Transactions inbox so a
bank line that is already booked elsewhere (a salary run, a Fortnox/manual
voucher, an invoice paid from the invoice page) can be linked to that existing
verifikat with no new bokföring — the capability previously lived only in
Reports → Bankavstämning.
- Extract the searchable MatchVerifikationPicker into a shared client component.
- New MatchVoucherDialog: resolves the tx's cash account, fetches candidates
ranked server-side by reconciliation confidence, links via
/api/reconciliation/bank/link (so the link is undoable in Bankavstämning).
- unmatched-entries route gains an optional transaction_id that ranks candidates
(ranking stays server-side; the recon lib is not client-safe).
- Inbox row's overflow (⋯) menu gains the new action.
Also fixes the Bankavstämning view: editing the date no longer auto-reloads
(applies on Filtrera / account change only) and Datum till defaults to today.
Co-Authored-By: Claude Opus 4.8 (1M context)
* fix(transactions): actionable Swedish errors when a bank tx cannot be deleted
The delete route returned a hardcoded English 409 that getErrorMessage mapped to
the misleading generic "En konflikt uppstod. Ladda om sidan...". Return
structured bilingual envelopes instead:
- TRANSACTION_DELETE_BOOKED (409) for a booked/linked row — steers the user to
unlink in Bankavstämning or storna the voucher.
- TRANSACTION_DELETE_HAS_AUDIT_TRAIL (409) for the real, common case where an
unbooked row carries payment_match_log rows: the cascade hits the
audit-immutability trigger (P0001), previously surfaced as a bare 500. Steers
the user to match-to-voucher or ignore instead.
Updates the DELETE test suite and adds the P0001 case.
Co-Authored-By: Claude Opus 4.8 (1M context)
* feat(settings): surface DPA and privacy policy links
The /dpa page (personuppgiftsbiträdesavtal, GDPR Art. 28) was complete but
linked from nowhere. Add a "Sekretess och avtal" card on Inställningar → Konto
linking to /privacy and /dpa, and a reciprocal link to the DPA from the privacy
policy's sub-processor section. (The DPA already links back to /privacy.)
Co-Authored-By: Claude Opus 4.8 (1M context)
* fix(review): address PR #623 feedback
- unmatched-entries: when transaction_id is supplied but resolves to no row in
the caller's company, return an empty candidate list instead of silently
falling back to the full unranked list (Compliance Swarm V8.2.1, high).
- MatchVoucherDialog: preserve a manually-picked voucher when the candidate
list reloads (e.g. "Visa alla datum") instead of discarding it (Greptile P2).
- DELETE /api/transactions/[id]: return the 404 as the structured
{ error: { code, message, message_en } } envelope like the handler's other
errors, for a uniform contract (Greptile P2). Test updated.
Co-Authored-By: Claude Opus 4.8 (1M context)
* feat(bookkeeping): allow editing notes on a committed journal entry
Saving a note on a posted verifikation failed with "Committed entries are
immutable": enforce_journal_entry_immutability() had no posted→posted path, so a
notes-only UPDATE fell through to the final RAISE. `notes` is internal annotation
metadata (not verifikation content under BFL 5 kap. / BFNAR 2013:2), so add a
narrow carve-out that permits a notes-only change on a committed entry — verified
with a whole-row to_jsonb() diff so any other field change still raises, and only
when status is unchanged. Period-lock enforcement is unaffected.
CREATE OR REPLACE in a new migration (same pattern as
20260428160000_fix_journal_entry_immutability_delete_bypass); the migration-017
protections are extended, never weakened. Covered by a pg-real test asserting a
notes edit succeeds while amount/description/account edits still fail.
(Already applied to production; committing the file + test for repo consistency.)
Co-Authored-By: Claude Opus 4.8 (1M context)
* fix(sandbox): make pre-staged pending operations executor-complete
The seeded pending_operations existed only as display previews — approving them
failed because commit executors in lib/pending-operations/commit.ts validate
required fields on "Godkänn". Seed a backing invoice_inbox_items row and fill the
supplier-invoice and categorize params with every field the executors require
(inbox_item_id, full items array; real uncategorized transaction_id + category),
so the sandbox approval queue is actually approvable end to end.
Co-Authored-By: Claude Opus 4.8 (1M context)
---------
Co-authored-by: Claude Opus 4.8 (1M context)
---
app/(dashboard)/settings/account/page.tsx | 32 +-
app/(dashboard)/transactions/page.tsx | 44 +++
app/(public)/privacy/page.tsx | 9 +
.../bank/unmatched-entries/route.ts | 46 ++-
app/api/sandbox/seed/route.ts | 95 +++++-
.../transactions/[id]/__tests__/route.test.ts | 35 ++-
app/api/transactions/[id]/route.ts | 57 +++-
app/api/transactions/route.ts | 11 +-
.../MatchVerifikationPicker.tsx | 178 ++++++++++++
components/reports/BankReconciliationView.tsx | 199 ++-----------
.../transactions/MatchVoucherDialog.tsx | 275 ++++++++++++++++++
.../transactions/TransactionInboxCard.tsx | 139 ++++++---
...ournal-entry-notes-immutability.pg.test.ts | 139 +++++++++
.../__tests__/bank-reconciliation.test.ts | 66 ++++-
lib/reconciliation/bank-reconciliation.ts | 41 ++-
messages/en.json | 7 +-
messages/sv.json | 7 +-
...8120000_allow_journal_entry_notes_edit.sql | 95 ++++++
...ctions_cash_account_id_repair_backfill.sql | 106 +++++++
.../transactions-cash-account-id.pg.test.ts | 113 +++++++
20 files changed, 1434 insertions(+), 260 deletions(-)
create mode 100644 components/reconciliation/MatchVerifikationPicker.tsx
create mode 100644 components/transactions/MatchVoucherDialog.tsx
create mode 100644 lib/bookkeeping/__tests__/journal-entry-notes-immutability.pg.test.ts
create mode 100644 supabase/migrations/20260608120000_allow_journal_entry_notes_edit.sql
create mode 100644 supabase/migrations/20260609120000_transactions_cash_account_id_repair_backfill.sql
diff --git a/app/(dashboard)/settings/account/page.tsx b/app/(dashboard)/settings/account/page.tsx
index 1c701b60..3dfa760e 100644
--- a/app/(dashboard)/settings/account/page.tsx
+++ b/app/(dashboard)/settings/account/page.tsx
@@ -2,10 +2,11 @@
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
+import Link from 'next/link'
import { useLocale, useTranslations } from 'next-intl'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
-import { Sun, Moon, Monitor, LogOut, Languages } from 'lucide-react'
+import { Sun, Moon, Monitor, LogOut, Languages, ExternalLink } from 'lucide-react'
import { useTheme } from 'next-themes'
import { createClient } from '@/lib/supabase/client'
import { SecuritySettings } from '@/components/settings/SecuritySettings'
@@ -158,6 +159,35 @@ export default function AccountSettingsPage() {
+ {/* Privacy & agreements — surface the otherwise-unlinked DPA + privacy policy */}
+
+
+
+ {tSettings('legal_title')}
+
+
+
+ {tSettings('legal_privacy')}
+
+
+
+ {tSettings('legal_dpa')}
+
+
+
+
+
+
{/* Delete account — only for non-sandbox */}
{!settings?.is_sandbox && }
diff --git a/app/(dashboard)/transactions/page.tsx b/app/(dashboard)/transactions/page.tsx
index 8bfd6c00..c5cb0eb5 100644
--- a/app/(dashboard)/transactions/page.tsx
+++ b/app/(dashboard)/transactions/page.tsx
@@ -33,6 +33,7 @@ import InboxZeroState from '@/components/transactions/InboxZeroState'
import SkattekontoInboxCard from '@/components/transactions/SkattekontoInboxCard'
import { SkattekontoMatchDialog } from '@/components/skattekonto/SkattekontoMatchDialog'
import InvoiceMatchDialog from '@/components/transactions/InvoiceMatchDialog'
+import { MatchVoucherDialog } from '@/components/transactions/MatchVoucherDialog'
import InvoicePicker from '@/components/transactions/InvoicePicker'
import SupplierInvoicePicker from '@/components/transactions/SupplierInvoicePicker'
import MatchAllocationDialog from '@/components/transactions/MatchAllocationDialog'
@@ -127,6 +128,9 @@ export default function TransactionsPage() {
const [supplierInvoicePickerTransaction, setSupplierInvoicePickerTransaction] = useState(null)
const [splitMatchOpen, setSplitMatchOpen] = useState(false)
const [splitMatchTransaction, setSplitMatchTransaction] = useState(null)
+ // "Matcha mot befintlig verifikation" — link a bank tx to an already-booked
+ // voucher (salary, Fortnox import, manual entry) with no new bokföring.
+ const [matchVoucherTx, setMatchVoucherTx] = useState(null)
const [bulkBookOpen, setBulkBookOpen] = useState(false)
const [isMatchingSupplierFromPicker, setIsMatchingSupplierFromPicker] = useState(false)
const [isMatchingFromPicker, setIsMatchingFromPicker] = useState(false)
@@ -1034,6 +1038,38 @@ export default function TransactionsPage() {
}
}
+ function openMatchVoucherDialog(transaction: TransactionWithInvoice) {
+ setMatchVoucherTx(transaction)
+ }
+
+ // Called by MatchVoucherDialog after /api/reconciliation/bank/link succeeds.
+ // The row is now booked (journal_entry_id set, is_business true) so the inbox
+ // filter drops it — animate it out the same way as the invoice-link path.
+ function handleVoucherLinked(transactionId: string, journalEntryId: string, voucherLabel: string) {
+ toast({
+ title: 'Bankhändelsen kopplad',
+ description: voucherLabel
+ ? `Kopplad till verifikation ${voucherLabel}. Ingen ny bokföring skapad.`
+ : 'Ingen ny bokföring skapad.',
+ })
+ setMatchVoucherTx(null)
+ setExitingIds((prev) => new Set(prev).add(transactionId))
+ setTimeout(() => {
+ setTransactions((prev) =>
+ prev.map((t) =>
+ t.id === transactionId
+ ? { ...t, is_business: true, journal_entry_id: journalEntryId }
+ : t,
+ ),
+ )
+ setExitingIds((prev) => {
+ const next = new Set(prev)
+ next.delete(transactionId)
+ return next
+ })
+ }, 350)
+ }
+
async function handleMatchInvoice(transactionId: string, invoiceId: string): Promise {
try {
const response = await fetch(`/api/transactions/${transactionId}/match-invoice`, {
@@ -1743,6 +1779,7 @@ export default function TransactionsPage() {
onOpenMatchDialog={openMatchDialog}
onOpenMatchInvoicePicker={openInvoiceMatchPicker}
onOpenSplitMatch={openSplitMatchDialog}
+ onOpenMatchVoucher={openMatchVoucherDialog}
onOpenCategoryDialog={openCategoryDialog}
onDelete={handleDeleteTransaction}
onEditTitle={openEditTitleDialog}
@@ -1849,6 +1886,13 @@ export default function TransactionsPage() {
onLinkToExisting={handleLinkToExistingVoucher}
/>
+ { if (!o) setMatchVoucherTx(null) }}
+ transaction={matchVoucherTx}
+ onLinked={handleVoucherLinked}
+ />
+
{
diff --git a/app/(public)/privacy/page.tsx b/app/(public)/privacy/page.tsx
index f1799094..58bfd8ba 100644
--- a/app/(public)/privacy/page.tsx
+++ b/app/(public)/privacy/page.tsx
@@ -1,4 +1,5 @@
import type { Metadata } from 'next'
+import Link from 'next/link'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { getBranding } from '@/lib/branding/service'
@@ -88,6 +89,14 @@ export default function PrivacyPolicyPage() {
vilka uppgifter som delas med respektive underbiträde, syftet samt var behandlingen sker
(GDPR Art. 13).
+
+ Behandlar du själv personuppgifter åt andra (kunder, leverantörer, anställda)? Se vårt
+ fullständiga{' '}
+
+ personuppgiftsbiträdesavtal (DPA)
+ {' '}
+ enligt GDPR Art. 28.
+
diff --git a/app/api/reconciliation/bank/unmatched-entries/route.ts b/app/api/reconciliation/bank/unmatched-entries/route.ts
index e504c8ab..7074a8df 100644
--- a/app/api/reconciliation/bank/unmatched-entries/route.ts
+++ b/app/api/reconciliation/bank/unmatched-entries/route.ts
@@ -1,7 +1,8 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
-import { fetchUnlinkedGLLines } from '@/lib/reconciliation/bank-reconciliation'
+import { fetchUnlinkedGLLines, tryReconcileTransaction } from '@/lib/reconciliation/bank-reconciliation'
import { requireCompanyId } from '@/lib/company/context'
+import type { Transaction } from '@/types'
export async function GET(request: Request) {
const supabase = await createClient()
@@ -17,6 +18,12 @@ export async function GET(request: Request) {
const dateFrom = searchParams.get('date_from') || undefined
const dateTo = searchParams.get('date_to') || undefined
const accountNumber = searchParams.get('account_number') || '1930'
+ // Optional: when set, rank the returned candidates for this specific bank
+ // transaction (used by the Transactions-page "Matcha mot befintlig
+ // verifikation" dialog). Ranking happens server-side on purpose —
+ // lib/reconciliation/bank-reconciliation pulls in server-only deps (event
+ // bus, match-log) and must never reach the client bundle.
+ const transactionId = searchParams.get('transaction_id') || undefined
// Defense-in-depth: only allow account numbers that the company has actually
// registered as a cash account. Without this, a curious caller could probe
@@ -40,5 +47,42 @@ export async function GET(request: Request) {
const lines = await fetchUnlinkedGLLines(supabase, companyId, accountNumber, dateFrom, dateTo)
+ if (transactionId) {
+ // company-scoped fetch (defense-in-depth). A malformed/foreign id yields no
+ // row → we fall through to the unranked list rather than erroring.
+ const { data: tx } = await supabase
+ .from('transactions')
+ .select('id, amount, date, currency, reference')
+ .eq('id', transactionId)
+ .eq('company_id', companyId)
+ .maybeSingle()
+
+ if (!tx) {
+ // transaction_id was supplied but doesn't resolve to a row in the
+ // caller's company — the ranking context is invalid. Return no candidates
+ // rather than silently falling back to the full unranked list, so a
+ // fabricated or foreign id can never yield a broader result set.
+ return NextResponse.json({ data: [] })
+ }
+
+ const txCurrency = (tx.currency as string | null) ?? 'SEK'
+ const txDate = tx.date as string
+ const ranked = lines
+ .map((line) => {
+ // Score each line in isolation; confidence 0 means "no auto-match
+ // rule fired" — the line still appears so the user can pick it
+ // manually (e.g. a salary or Fortnox voucher with a tweaked date).
+ const match = tryReconcileTransaction(tx as unknown as Transaction, [line], txCurrency)
+ return { ...line, confidence: match?.confidence ?? 0 }
+ })
+ .sort((a, b) => {
+ if (b.confidence !== a.confidence) return b.confidence - a.confidence
+ const da = Math.abs(new Date(a.entry_date).getTime() - new Date(txDate).getTime())
+ const db = Math.abs(new Date(b.entry_date).getTime() - new Date(txDate).getTime())
+ return da - db
+ })
+ return NextResponse.json({ data: ranked })
+ }
+
return NextResponse.json({ data: lines })
}
diff --git a/app/api/sandbox/seed/route.ts b/app/api/sandbox/seed/route.ts
index 42d46024..b9d26174 100644
--- a/app/api/sandbox/seed/route.ts
+++ b/app/api/sandbox/seed/route.ts
@@ -467,7 +467,7 @@ export async function POST(request: Request) {
if (jelError) throw jelError
// 11. Create transactions
- const { error: txError } = await supabase
+ const { data: txRows, error: txError } = await supabase
.from('transactions')
.insert([
// Categorized expenses
@@ -553,9 +553,17 @@ export async function POST(request: Request) {
is_business: null,
},
])
+ .select('id, description')
if (txError) throw txError
+ // Lookup so the pre-staged categorize_transaction operation below can
+ // reference a real, uncategorized transaction by id (descriptions are
+ // unique in this seed set).
+ const txMap = Object.fromEntries(
+ (txRows ?? []).map(t => [t.description as string, t.id as string])
+ )
+
// 12. Create deadlines
const momsDeadline = new Date(today)
momsDeadline.setMonth(momsDeadline.getMonth() + 2)
@@ -764,10 +772,56 @@ export async function POST(request: Request) {
// top-up path all use the same helper).
await ensureSandboxAgentProfile(supabase, companyId)
- // 16. Pre-staged pending_operations so /pending isn't empty.
+ // 16. Inbox item backing the pre-staged supplier-invoice approval below.
+ // commitCreateSupplierInvoiceFromInbox does an idempotency + FK lookup
+ // against invoice_inbox_items by inbox_item_id before it creates anything,
+ // so the "Godkänn" path can only succeed if a real inbox row exists.
+ // status is constrained to 'received' | 'error' (migration 20260504180000).
+ const { data: inboxRow, error: inboxError } = await supabase
+ .from('invoice_inbox_items')
+ .insert({
+ user_id: userId,
+ company_id: companyId,
+ status: 'received',
+ source: 'upload',
+ document_type: 'supplier_invoice',
+ matched_supplier_id: supplierMap['Demokafé AB'],
+ extracted_data: {
+ supplier: { name: 'Demokafé AB' },
+ invoice: {
+ invoiceNumber: 'INKOMMANDE-2026-001',
+ invoiceDate: toDateStr(fiveDaysAgo),
+ dueDate: toDateStr(sevenDaysFromNow),
+ currency: 'SEK',
+ vatTreatment: 'reduced_12',
+ },
+ totals: { subtotal: 240, vat: 28.80, total: 268.80 },
+ lineItems: [
+ {
+ description: 'Kundmöte Demokafé (representation)',
+ quantity: 1,
+ unit: 'st',
+ unit_price: 240,
+ line_total: 240,
+ account_number: '5810',
+ vat_rate: 12,
+ vat_amount: 28.80,
+ },
+ ],
+ },
+ })
+ .select('id')
+ .single()
+
+ if (inboxError) throw inboxError
+
+ // 17. Pre-staged pending_operations so /pending isn't empty.
// These are the kind of operation the AI agent would stage; pre-seeded
// here so the user can see the approval queue UI (preview, period
- // status, risk level) without having to invoke the disabled AI.
+ // status, risk level) without having to invoke the disabled AI. Each
+ // params blob must be executor-complete — the commit executors in
+ // lib/pending-operations/commit.ts validate required fields on "Godkänn",
+ // so a display-only preview with a hollow params object fails to save.
// actor_type='agent_chat' + risk_level on the row itself is required by
// pending_operations_chat_insert (the only RLS policy that lets a
// user-scoped client INSERT into this table).
@@ -786,14 +840,37 @@ export async function POST(request: Request) {
// of colliding with the Demokafé '88245' already booked above
// (BFL 5 kap — each affärshändelse must be recorded exactly once).
title: 'Registrera leverantörsfaktura — Demokafé (representation, nytt underlag)',
+ // Mirrors what gnubok_create_supplier_invoice_from_inbox would stage:
+ // every field commitCreateSupplierInvoiceFromInbox requires
+ // (inbox_item_id, supplier_id, supplier_invoice_number, invoice_date,
+ // finite subtotal/vat_amount/total, and a non-empty items array).
params: {
+ inbox_item_id: inboxRow.id,
supplier_id: supplierMap['Demokafé AB'],
+ document_id: null,
supplier_invoice_number: 'INKOMMANDE-2026-001',
invoice_date: toDateStr(fiveDaysAgo),
due_date: toDateStr(sevenDaysFromNow),
- total: 268.80,
+ currency: 'SEK',
+ exchange_rate: null,
+ vat_treatment: 'reduced_12',
+ subtotal: 240,
vat_amount: 28.80,
- account_number: '5810',
+ total: 268.80,
+ notes: 'Representation – kundmöte (demo)',
+ items: [
+ {
+ line_number: 1,
+ description: 'Kundmöte Demokafé (representation)',
+ quantity: 1,
+ unit: 'st',
+ unit_price: 240,
+ line_total: 240,
+ account_number: '5810',
+ vat_rate: 12,
+ vat_amount: 28.80,
+ },
+ ],
},
preview_data: {
// Representation @ 12% VAT (café meal), 240 SEK excl. VAT for
@@ -816,9 +893,13 @@ export async function POST(request: Request) {
actor_type: 'agent_chat',
risk_level: 'low',
title: 'Bokför insättning — bankgiro',
+ // commitCategorizeTransaction needs a real uncategorized
+ // transaction_id + a category that resolves to an account mapping.
+ // income_services → 3001 (Försäljning tjänster 25%), matching the
+ // preview's 1930 / 2611 / 3001 split for the 1 200 kr deposit.
params: {
- account_number: '3001',
- is_business: true,
+ transaction_id: txMap['INSÄTTNING BANKGIRO'],
+ category: 'income_services',
vat_treatment: 'standard_25',
},
preview_data: {
diff --git a/app/api/transactions/[id]/__tests__/route.test.ts b/app/api/transactions/[id]/__tests__/route.test.ts
index 1dbef665..2534c901 100644
--- a/app/api/transactions/[id]/__tests__/route.test.ts
+++ b/app/api/transactions/[id]/__tests__/route.test.ts
@@ -62,19 +62,21 @@ describe('DELETE /api/transactions/[id]', () => {
const { status, body } = await parseJsonResponse(response)
expect(status).toBe(404)
- expect(body).toEqual({ error: 'Transaction not found' })
+ expect((body as { error: { code: string } }).error.code).toBe('TRANSACTION_NOT_FOUND')
})
- it('returns 409 when transaction has a journal entry', async () => {
+ it('returns 409 with an actionable code when transaction has a journal entry', async () => {
const tx = makeTransaction({ journal_entry_id: 'je-1', bank_connection_id: null, import_source: null })
enqueue({ data: tx, error: null })
const request = new Request('http://localhost/api/transactions/tx-1', { method: 'DELETE' })
const response = await DELETE(request, createMockRouteParams({ id: 'tx-1' }))
- const { status, body } = await parseJsonResponse<{ error: string }>(response)
+ const { status, body } = await parseJsonResponse<{ error: { code: string; message: string } }>(response)
expect(status).toBe(409)
- expect(body.error).toContain('booked')
+ expect(body.error.code).toBe('TRANSACTION_DELETE_BOOKED')
+ // Swedish, actionable — not the generic "Ladda om sidan" 409 fallback.
+ expect(body.error.message).toMatch(/Bankavstämning|storna/)
})
it('allows deleting unbooked bank-synced transactions', async () => {
@@ -116,17 +118,36 @@ describe('DELETE /api/transactions/[id]', () => {
expect(body).toEqual({ success: true })
})
- it('returns 500 when deletion fails', async () => {
+ it('returns 500 with a structured code when deletion fails', async () => {
const tx = makeTransaction({ journal_entry_id: null, bank_connection_id: null, import_source: null })
enqueue({ data: tx, error: null }) // fetch
enqueue({ data: null, error: { message: 'DB error' } }) // delete fails
const request = new Request('http://localhost/api/transactions/tx-1', { method: 'DELETE' })
const response = await DELETE(request, createMockRouteParams({ id: 'tx-1' }))
- const { status, body } = await parseJsonResponse(response)
+ const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(500)
- expect(body).toEqual({ error: 'Failed to delete transaction' })
+ expect(body.error.code).toBe('TRANSACTION_DELETE_FAILED')
+ })
+
+ it('returns 409 with an audit-trail code when the immutability trigger blocks the delete', async () => {
+ // An unbooked row with payment_match_log rows: the cascade hits the
+ // audit_log_immutable trigger (P0001), not a clean FK error.
+ const tx = makeTransaction({ journal_entry_id: null, bank_connection_id: 'bc-1', import_source: null })
+ enqueue({ data: tx, error: null }) // fetch
+ enqueue({
+ data: null,
+ error: { code: 'P0001', message: 'Audit log entries cannot be modified or deleted' },
+ }) // delete blocked by trigger
+
+ const request = new Request('http://localhost/api/transactions/tx-1', { method: 'DELETE' })
+ const response = await DELETE(request, createMockRouteParams({ id: 'tx-1' }))
+ const { status, body } = await parseJsonResponse<{ error: { code: string; message: string } }>(response)
+
+ expect(status).toBe(409)
+ expect(body.error.code).toBe('TRANSACTION_DELETE_HAS_AUDIT_TRAIL')
+ expect(body.error.message).toMatch(/matchningshistorik|Bankavstämning/)
})
})
diff --git a/app/api/transactions/[id]/route.ts b/app/api/transactions/[id]/route.ts
index f1b608c7..51164a44 100644
--- a/app/api/transactions/[id]/route.ts
+++ b/app/api/transactions/[id]/route.ts
@@ -35,13 +35,33 @@ export async function DELETE(
.single()
if (fetchError || !transaction) {
- return NextResponse.json({ error: 'Transaction not found' }, { status: 404 })
+ return NextResponse.json(
+ {
+ error: {
+ code: 'TRANSACTION_NOT_FOUND',
+ message: 'Transaktionen hittades inte.',
+ message_en: 'Transaction not found.',
+ },
+ },
+ { status: 404 }
+ )
}
- // Guard: only unbooked transactions can be deleted
+ // Guard: only unbooked transactions can be deleted. A booked/matched row is
+ // räkenskapsinformation — the fix is to unlink (reconciliation) or storno, not
+ // delete. Return a structured bilingual envelope so the UI shows this clear,
+ // actionable message instead of the generic "Ladda om sidan" 409 fallback.
if (transaction.journal_entry_id) {
return NextResponse.json(
- { error: 'Cannot delete a booked transaction. Use reversal (storno) instead.' },
+ {
+ error: {
+ code: 'TRANSACTION_DELETE_BOOKED',
+ message:
+ 'Transaktionen är redan bokförd eller kopplad till en verifikation och kan inte raderas. Koppla bort den under Rapporter → Bankavstämning om kopplingen är fel, eller storna verifikationen.',
+ message_en:
+ 'The transaction is already booked or linked to a journal entry and cannot be deleted. Unlink it under Reports → Bank reconciliation if the link is wrong, or reverse (storno) the voucher.',
+ },
+ },
{ status: 409 }
)
}
@@ -53,7 +73,36 @@ export async function DELETE(
.eq('company_id', companyId)
if (deleteError) {
- return NextResponse.json({ error: 'Failed to delete transaction' }, { status: 500 })
+ // An unbooked row can still carry payment_match_log rows (written at ingest
+ // for every auto-suggested match). Their FK cascades on delete, but the
+ // audit-immutability trigger raises P0001 — surface that as an actionable
+ // message (match or ignore instead) rather than a bare 500.
+ const code = (deleteError as { code?: string }).code
+ const message = (deleteError as { message?: string }).message ?? ''
+ if (code === 'P0001' || /Audit log entries cannot be modified or deleted/i.test(message)) {
+ return NextResponse.json(
+ {
+ error: {
+ code: 'TRANSACTION_DELETE_HAS_AUDIT_TRAIL',
+ message:
+ 'Transaktionen kan inte raderas eftersom den har en kopplad matchningshistorik (räkenskapsinformation, BFL 7 kap.). Matcha den mot en befintlig verifikation, eller ignorera den under Rapporter → Bankavstämning om du inte vill bokföra den.',
+ message_en:
+ 'The transaction cannot be deleted because it has linked match-history records (accounting information, BFL ch. 7). Match it to an existing voucher, or ignore it under Reports → Bank reconciliation if you do not want to book it.',
+ },
+ },
+ { status: 409 }
+ )
+ }
+ return NextResponse.json(
+ {
+ error: {
+ code: 'TRANSACTION_DELETE_FAILED',
+ message: 'Kunde inte ta bort transaktionen. Försök igen.',
+ message_en: 'Could not delete the transaction. Please try again.',
+ },
+ },
+ { status: 500 }
+ )
}
return NextResponse.json({ success: true })
diff --git a/app/api/transactions/route.ts b/app/api/transactions/route.ts
index 709fa5f3..810dcd8f 100644
--- a/app/api/transactions/route.ts
+++ b/app/api/transactions/route.ts
@@ -1,6 +1,7 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
+import { scopeTransactionsToAccount } from '@/lib/reconciliation/bank-reconciliation'
const MAX_ROWS = 500
@@ -75,12 +76,10 @@ export async function GET(request: Request) {
// OR legacy NULL rows of the same currency (so nothing disappears mid-
// backfill). With only a currency (no account), filter by currency. With
// neither (e.g. the company-wide only_ignored recovery list), no scope.
- if (cashAccountId) {
- query = query.or(
- `cash_account_id.eq.${cashAccountId},and(cash_account_id.is.null,currency.eq.${derivedCurrency ?? 'SEK'})`,
- )
- } else if (derivedCurrency) {
- query = query.eq('currency', derivedCurrency)
+ // Shares one implementation with the reconciliation lib so the filter shape
+ // can't drift between the status card and these lists.
+ if (cashAccountId || derivedCurrency) {
+ query = scopeTransactionsToAccount(query, cashAccountId, derivedCurrency ?? 'SEK')
}
if (dateFrom) query = query.gte('date', dateFrom)
if (dateTo) query = query.lte('date', dateTo)
diff --git a/components/reconciliation/MatchVerifikationPicker.tsx b/components/reconciliation/MatchVerifikationPicker.tsx
new file mode 100644
index 00000000..672f40e8
--- /dev/null
+++ b/components/reconciliation/MatchVerifikationPicker.tsx
@@ -0,0 +1,178 @@
+'use client'
+
+import { useState, useEffect, useMemo, useRef } from 'react'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Search, X } from 'lucide-react'
+import { formatCurrency, formatDate } from '@/lib/utils'
+import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
+
+/**
+ * A posted journal entry line on a cash account (e.g. 1930) not yet linked to
+ * any bank transaction — a candidate for manual reconciliation. Mirrors the
+ * `UnlinkedGLLine` returned by GET /api/reconciliation/bank/unmatched-entries.
+ *
+ * Defined here (not imported from lib/reconciliation/bank-reconciliation) so the
+ * client bundle never pulls in that module's server-only dependencies (event
+ * bus, match-log). The optional `confidence` is attached when the endpoint
+ * ranks candidates for a specific transaction.
+ */
+export interface UnlinkedGLLine {
+ line_id: string
+ journal_entry_id: string
+ debit_amount: number
+ credit_amount: number
+ line_description: string | null
+ entry_date: string
+ voucher_number: number
+ voucher_series: string
+ entry_description: string
+ source_type: string
+ confidence?: number
+}
+
+interface MatchPickerProps {
+ glLines: UnlinkedGLLine[]
+ value: string
+ onChange: (journalEntryId: string) => void
+ disabled?: boolean
+ placeholder?: string
+}
+
+/**
+ * Inline combobox for choosing a journal entry to match a bank transaction
+ * against. The native