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