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 { + setSearch(e.target.value) + setOpen(true) + }} + onFocus={() => setOpen(true)} + placeholder={placeholder} + disabled={disabled} + className="pl-9" + /> + + {open && ( +
+ {filtered.length === 0 ? ( +
+ Inga verifikationer matchar "{search}" +
+ ) : ( +
+ {filtered.map((line) => { + const amount = line.debit_amount > 0 ? line.debit_amount : -line.credit_amount + return ( + + ) + })} + {glLines.length > filtered.length && ( +
+ Visar {filtered.length} av {glLines.length} — sök för att filtrera fler. +
+ )} +
+ )} +
+ )} + + ) +} diff --git a/components/reports/BankReconciliationView.tsx b/components/reports/BankReconciliationView.tsx index d22c5971..a5dbd4ad 100644 --- a/components/reports/BankReconciliationView.tsx +++ b/components/reports/BankReconciliationView.tsx @@ -1,16 +1,16 @@ 'use client' -import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react' +import { useState, useEffect, useCallback, useRef } from 'react' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { Label } from '@/components/ui/label' import { Badge } from '@/components/ui/badge' -import { Input } from '@/components/ui/input' import { AccountNumber } from '@/components/ui/account-number' -import { AlertCircle, ChevronDown, ChevronRight, Link2, Unlink, Play, Eye, EyeOff, PiggyBank, MoreHorizontal, Search, X } from 'lucide-react' +import { AlertCircle, ChevronDown, ChevronRight, Link2, Unlink, Play, Eye, EyeOff, PiggyBank, MoreHorizontal } from 'lucide-react' import { formatCurrency, formatDate } from '@/lib/utils' import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver' import { CashAccountSelector } from '@/components/common/CashAccountSelector' +import { MatchVerifikationPicker, type UnlinkedGLLine } from '@/components/reconciliation/MatchVerifikationPicker' import { DropdownMenu, DropdownMenuContent, @@ -81,19 +81,6 @@ interface ReconciliationStatus { unmatched_gl_line_count: number } -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 -} - interface UnmatchedTransaction { id: string date: string @@ -127,153 +114,6 @@ interface DryRunMatch { confidence: number } -// ============================================================ -// Searchable verifikation picker -// ============================================================ - -/** - * Inline combobox for choosing a journal entry to match a bank transaction - * against. The native { - setSearch(e.target.value) - setOpen(true) - }} - onFocus={() => setOpen(true)} - placeholder={placeholder} - disabled={disabled} - className="pl-9" - /> - - {open && ( -
- {filtered.length === 0 ? ( -
- Inga verifikationer matchar "{search}" -
- ) : ( -
- {filtered.map((line) => { - const amount = line.debit_amount > 0 ? line.debit_amount : -line.credit_amount - return ( - - ) - })} - {glLines.length > filtered.length && ( -
- Visar {filtered.length} av {glLines.length} — sök för att filtrera fler. -
- )} -
- )} -
- )} - - ) -} - // ============================================================ // Component // ============================================================ @@ -286,10 +126,26 @@ export function BankReconciliationView() { const [loading, setLoading] = useState(true) const [error, setError] = useState(null) + // dateFrom stays empty by default (full history) so nothing the user needs to + // reconcile is hidden on first load. dateTo defaults to today so the field + // isn't a blank "åååå-mm-dd" and the upper bound is concrete. const [dateFrom, setDateFrom] = useState('') - const [dateTo, setDateTo] = useState('') + const [dateTo, setDateTo] = useState(() => new Date().toISOString().slice(0, 10)) const [accountNumber, setAccountNumber] = useState('1930') const [cashAccounts, setCashAccounts] = useState([]) + // Date filters apply on demand (the "Filtrera" button or an account switch), + // never on every keystroke. Editing a date used to re-create fetchAll and + // re-trigger its effect — the "switching months reloads automatically" + // annoyance. fetchAll reads the live dates from refs so an explicit run always + // uses the latest typed values without putting them in its dependency array. + const dateFromRef = useRef(dateFrom) + const dateToRef = useRef(dateTo) + useEffect(() => { + dateFromRef.current = dateFrom + }, [dateFrom]) + useEffect(() => { + dateToRef.current = dateTo + }, [dateTo]) const [dryRunResults, setDryRunResults] = useState(null) const [runLoading, setRunLoading] = useState(false) @@ -349,17 +205,19 @@ export function BankReconciliationView() { setLoading(true) setError(null) try { + const fromValue = dateFromRef.current + const toValue = dateToRef.current const params = new URLSearchParams() - if (dateFrom) params.set('date_from', dateFrom) - if (dateTo) params.set('date_to', dateTo) + if (fromValue) params.set('date_from', fromValue) + if (toValue) params.set('date_to', toValue) params.set('account_number', accountNumber) const qs = `?${params}` const txParams = new URLSearchParams() txParams.set('currency', accountCurrency) txParams.set('account_number', accountNumber) - if (dateFrom) txParams.set('date_from', dateFrom) - if (dateTo) txParams.set('date_to', dateTo) + if (fromValue) txParams.set('date_from', fromValue) + if (toValue) txParams.set('date_to', toValue) const unmatchedQs = `?unmatched=true&${txParams}` const reconciledQs = `?reconciled=true&${txParams}` @@ -410,7 +268,10 @@ export function BankReconciliationView() { // it off while the fresh one is still running. if (!signal.aborted) setLoading(false) } - }, [dateFrom, dateTo, accountNumber, accountCurrency]) + // Deliberately excludes dateFrom/dateTo: editing a date must NOT auto-fetch + // (it read from refs above). Re-runs only on account / currency change and + // mount; the "Filtrera" button calls fetchAll() explicitly for date changes. + }, [accountNumber, accountCurrency]) useEffect(() => { fetchAll() diff --git a/components/transactions/MatchVoucherDialog.tsx b/components/transactions/MatchVoucherDialog.tsx new file mode 100644 index 00000000..16f2f100 --- /dev/null +++ b/components/transactions/MatchVoucherDialog.tsx @@ -0,0 +1,275 @@ +'use client' + +import { useCallback, useEffect, useState } from 'react' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { + MatchVerifikationPicker, + type UnlinkedGLLine, +} from '@/components/reconciliation/MatchVerifikationPicker' +import { formatCurrency, formatDate } from '@/lib/utils' +import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver' +import { getErrorMessage } from '@/lib/errors/get-error-message' +import { useToast } from '@/components/ui/use-toast' +import { ArrowUpRight, ArrowDownRight, Loader2 } from 'lucide-react' +import type { TransactionWithInvoice } from './transaction-types' +import type { CashAccount } from '@/types' + +interface MatchVoucherDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + transaction: TransactionWithInvoice | null + /** Called after a successful link. voucherLabel is the picked verifikat's label (e.g. "A-42"). */ + onLinked: (transactionId: string, journalEntryId: string, voucherLabel: string) => void +} + +// ±30 days around the transaction date — wide enough to catch a salary or +// supplier voucher booked a few days off the bank value date, narrow enough to +// keep the candidate list short. "Visa alla" drops the window entirely. +const WINDOW_DAYS = 30 + +function shiftDate(isoDate: string, deltaDays: number): string { + const d = new Date(isoDate) + if (Number.isNaN(d.getTime())) return isoDate + d.setDate(d.getDate() + deltaDays) + return d.toISOString().slice(0, 10) +} + +/** Resolve which cash account (BAS ledger number) this transaction reconciles against. */ +function resolveAccount( + cashAccounts: CashAccount[], + tx: TransactionWithInvoice, +): { account: string; fallback: boolean } { + // 1. Bound row → its own account. + if (tx.cash_account_id) { + const bound = cashAccounts.find((a) => a.id === tx.cash_account_id) + if (bound) return { account: bound.ledger_account, fallback: false } + } + // 2. Legacy NULL → the sole enabled account of the transaction's currency. + const sameCurrency = cashAccounts.filter((a) => a.enabled && a.currency === tx.currency) + if (sameCurrency.length === 1) return { account: sameCurrency[0].ledger_account, fallback: false } + // 3. Give up gracefully on 1930 (the default SEK företagskonto). + return { account: '1930', fallback: true } +} + +export function MatchVoucherDialog({ + open, + onOpenChange, + transaction, + onLinked, +}: MatchVoucherDialogProps) { + const { toast } = useToast() + const [glLines, setGlLines] = useState([]) + const [selected, setSelected] = useState('') + const [accountNumber, setAccountNumber] = useState('1930') + const [accountFallback, setAccountFallback] = useState(false) + const [loading, setLoading] = useState(false) + const [submitting, setSubmitting] = useState(false) + const [wideRange, setWideRange] = useState(false) + + const loadCandidates = useCallback( + async (tx: TransactionWithInvoice, wide: boolean) => { + setLoading(true) + try { + // Resolve the settlement account from the company's cash accounts. + let account = '1930' + let fallback = true + try { + const caRes = await fetch('/api/cash-accounts') + const caJson = await caRes.json() + const accounts = (caJson.data ?? []) as CashAccount[] + const resolved = resolveAccount(accounts, tx) + account = resolved.account + fallback = resolved.fallback + } catch { + // Network hiccup — fall back to 1930 and let the user see the note. + } + setAccountNumber(account) + setAccountFallback(fallback) + + const params = new URLSearchParams() + params.set('account_number', account) + params.set('transaction_id', tx.id) + if (!wide) { + params.set('date_from', shiftDate(tx.date, -WINDOW_DAYS)) + params.set('date_to', shiftDate(tx.date, WINDOW_DAYS)) + } + + const res = await fetch(`/api/reconciliation/bank/unmatched-entries?${params}`) + const json = await res.json() + const lines = (json.data ?? []) as UnlinkedGLLine[] + setGlLines(lines) + // Pre-select a strong auto-match (exact/reference/date-range) so the + // common case is one click. Fuzzy (<0.85) is left for the user to confirm. + // Auto-select a strong match only when nothing is chosen yet. Toggling + // "Visa alla datum" reloads with a wider set — it must NOT discard a + // voucher the user already picked. (selected resets to '' on close.) + const top = lines[0] + setSelected((prev) => + prev ? prev : top && (top.confidence ?? 0) >= 0.85 ? top.journal_entry_id : '', + ) + } finally { + setLoading(false) + } + }, + [], + ) + + // (Re)load whenever the dialog opens for a transaction, or the range widens. + useEffect(() => { + if (!open || !transaction) return + void loadCandidates(transaction, wideRange) + }, [open, transaction, wideRange, loadCandidates]) + + // Reset transient state when the dialog closes so the next open starts clean. + useEffect(() => { + if (open) return + setGlLines([]) + setSelected('') + setWideRange(false) + setAccountFallback(false) + }, [open]) + + if (!transaction) return null + + const isIncome = transaction.amount > 0 + const selectedLine = glLines.find((l) => l.journal_entry_id === selected) ?? null + + async function handleConfirm() { + if (!transaction || !selected) return + setSubmitting(true) + try { + const res = await fetch('/api/reconciliation/bank/link', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + transaction_id: transaction.id, + journal_entry_id: selected, + account_number: accountNumber, + }), + }) + const result = await res.json() + if (!res.ok || result.error) { + toast({ + title: 'Kunde inte koppla', + description: getErrorMessage(result, { context: 'transaction', statusCode: res.status }), + variant: 'destructive', + }) + return + } + const label = selectedLine ? formatVoucher(selectedLine) : '' + onLinked(transaction.id, selected, label) + } catch { + toast({ + title: 'Kunde inte koppla', + description: 'Ett fel uppstod. Försök igen.', + variant: 'destructive', + }) + } finally { + setSubmitting(false) + } + } + + return ( + + + + Matcha mot befintlig verifikation + + Koppla bankhändelsen till en verifikation som redan är bokförd (t.ex. en + lön eller en post importerad från Fortnox). Ingen ny bokföring skapas. + + + + {/* Transaction summary */} +
+ + {isIncome ? : } + +
+

{transaction.description}

+

{formatDate(transaction.date)}

+
+ + {isIncome ? '+' : ''} + {formatCurrency(transaction.amount, transaction.currency)} + +
+ + {accountFallback && ( +

+ Avstämning mot 1930. Hör transaktionen till ett annat bankkonto? Stäm av + det under Rapporter → Bankavstämning. +

+ )} + + {/* Candidate picker */} +
+ {loading ? ( +
+ + Söker verifikationer… +
+ ) : glLines.length === 0 ? ( +
+

Inga omatchade verifikationer på {accountNumber} i perioden.

+ {!wideRange && ( + + )} +
+ ) : ( + <> + {selectedLine && (selectedLine.confidence ?? 0) >= 0.85 && ( + Föreslagen träff + )} + + {!wideRange && ( + + )} + + )} +
+ + + + + +
+
+ ) +} diff --git a/components/transactions/TransactionInboxCard.tsx b/components/transactions/TransactionInboxCard.tsx index 9139f287..7c7c4f64 100644 --- a/components/transactions/TransactionInboxCard.tsx +++ b/components/transactions/TransactionInboxCard.tsx @@ -19,13 +19,22 @@ import { AlertCircle, ArrowUpRight, ArrowDownRight, + FileSearch, FileText, Link2, Loader2, + MoreHorizontal, Pencil, Split, Trash2, } from 'lucide-react' +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, +} from '@/components/ui/dropdown-menu' import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions' // True when the AI tier is active — gates user-facing strings that promise @@ -53,6 +62,9 @@ interface TransactionInboxCardProps { * detection as the single-pick picker. Optional so legacy callers stay * source-compatible. */ onOpenSplitMatch?: (transaction: TransactionWithInvoice) => void + /** Open the existing-verifikat matcher — link the bank tx to an already-booked + * voucher (salary, Fortnox import, manual entry) with no new bokföring. */ + onOpenMatchVoucher?: (transaction: TransactionWithInvoice) => void onOpenCategoryDialog: (transaction: TransactionWithInvoice) => void onDelete?: (id: string) => void /** Open the edit-title dialog. Only wired for editable (unbooked/unmatched) rows. */ @@ -70,6 +82,7 @@ export default function TransactionInboxCard({ onOpenMatchDialog, onOpenMatchInvoicePicker, onOpenSplitMatch, + onOpenMatchVoucher, onOpenCategoryDialog, onDelete, onEditTitle, @@ -197,6 +210,18 @@ export default function TransactionInboxCard({ ? 'Dela inbetalningen på flera fakturor' : 'Dela utbetalningen på flera leverantörsfakturor' + // Secondary row actions are collapsed into a single ⋯ overflow menu to keep + // the inbox row uncluttered. Bokför + the invoice-match button stay inline. + // "Matcha mot befintlig verifikation" — link to an already-booked voucher. + // Available on any unbooked row (income or expense), independent of whether an + // invoice match was auto-detected: the user may want to point the bank line at + // an existing salary/Fortnox/manual voucher instead of confirming a payment. + const showMatchVoucherItem = isDeletable && !!onOpenMatchVoucher + const showSplitItem = showInvoiceMatchButton && !!onOpenSplitMatch + const showEditItem = isTitleEditable && !!onEditTitle + const showDeleteItem = isDeletable && !!onDelete + const showOverflowMenu = showMatchVoucherItem || showSplitItem || showEditItem || showDeleteItem + return ( )} - {showInvoiceMatchButton && onOpenSplitMatch && ( - - )} {/* The Paperclip indicator next to the description (TransactionAttachmentIndicator) is the single click target for opening the underlag. We deliberately don't @@ -299,36 +308,74 @@ export default function TransactionInboxCard({ Per-transaction agent help has moved to Dokumentinkorgen: match the underlag to the transaction and ask from there, where the receipt/invoice is in view. */} - {isTitleEditable && onEditTitle && ( - - )} - {isDeletable && onDelete && ( - + {/* Secondary actions (split, edit, delete) collapse into a ⋯ + overflow menu so the row stays uncluttered. */} + {showOverflowMenu && ( + + + + + + {showMatchVoucherItem && ( + { + e.stopPropagation() + onOpenMatchVoucher!(transaction) + }} + > + + {t('match_voucher_btn')} + + )} + {showSplitItem && ( + { + e.stopPropagation() + onOpenSplitMatch!(transaction) + }} + > + + {splitMatchLabel} + + )} + {showEditItem && ( + { + e.stopPropagation() + onEditTitle!(transaction) + }} + > + + {t('edit_title_aria')} + + )} + {showDeleteItem && ( + <> + {(showMatchVoucherItem || showSplitItem || showEditItem) && } + { + e.stopPropagation() + onDelete!(transaction.id) + }} + > + + {t('delete_aria')} + + + )} + + )} )} diff --git a/lib/bookkeeping/__tests__/journal-entry-notes-immutability.pg.test.ts b/lib/bookkeeping/__tests__/journal-entry-notes-immutability.pg.test.ts new file mode 100644 index 00000000..d588797a --- /dev/null +++ b/lib/bookkeeping/__tests__/journal-entry-notes-immutability.pg.test.ts @@ -0,0 +1,139 @@ +import { randomUUID } from 'node:crypto' +import { describe, expect, it } from 'vitest' +import { getPool } from '@/tests/pg/setup' +import { insertBalancedLines, seedCompany } from '@/tests/pg/fixtures' + +// Post a journal entry with balanced lines, going through draft so the +// line-immutability + balance triggers are satisfied. Returns the entry id. +async function insertPostedEntry(params: { + userId: string + companyId: string + fiscalPeriodId: string + voucherNumber: number + notes?: string | null +}): Promise { + const id = randomUUID() + await getPool().query( + `INSERT INTO public.journal_entries + (id, user_id, company_id, fiscal_period_id, voucher_number, voucher_series, + entry_date, description, source_type, status, notes) + VALUES ($1, $2, $3, $4, $5, 'A', '2026-06-01', 'Test entry', 'manual', 'draft', $6)`, + [id, params.userId, params.companyId, params.fiscalPeriodId, params.voucherNumber, params.notes ?? null], + ) + await insertBalancedLines(id) + await getPool().query( + `UPDATE public.journal_entries SET status = 'posted' WHERE id = $1`, + [id], + ) + return id +} + +describe('enforce_journal_entry_immutability.pg — notes-only edits', () => { + it('allows setting notes on a posted entry (the reported bug)', async () => { + const { userId, companyId, fiscalPeriodId } = await seedCompany() + const entryId = await insertPostedEntry({ userId, companyId, fiscalPeriodId, voucherNumber: 1 }) + + await getPool().query( + `UPDATE public.journal_entries SET notes = $1 WHERE id = $2`, + ['Underlag saknas, frågar kunden', entryId], + ) + const after = await getPool().query<{ notes: string | null; status: string }>( + `SELECT notes, status FROM public.journal_entries WHERE id = $1`, + [entryId], + ) + expect(after.rows[0]!.notes).toBe('Underlag saknas, frågar kunden') + expect(after.rows[0]!.status).toBe('posted') + }) + + it('allows clearing notes (set to NULL) on a posted entry', async () => { + const { userId, companyId, fiscalPeriodId } = await seedCompany() + const entryId = await insertPostedEntry({ + userId, companyId, fiscalPeriodId, voucherNumber: 1, notes: 'existing note', + }) + + await getPool().query( + `UPDATE public.journal_entries SET notes = NULL WHERE id = $1`, + [entryId], + ) + const after = await getPool().query<{ notes: string | null }>( + `SELECT notes FROM public.journal_entries WHERE id = $1`, + [entryId], + ) + expect(after.rows[0]!.notes).toBeNull() + }) + + it('allows notes edits on a reversed entry', async () => { + const { userId, companyId, fiscalPeriodId } = await seedCompany() + const entryId = await insertPostedEntry({ userId, companyId, fiscalPeriodId, voucherNumber: 1 }) + await getPool().query( + `UPDATE public.journal_entries SET status = 'reversed' WHERE id = $1`, + [entryId], + ) + + await getPool().query( + `UPDATE public.journal_entries SET notes = 'Makulerad pga dubbelbokning' WHERE id = $1`, + [entryId], + ) + const after = await getPool().query<{ notes: string | null; status: string }>( + `SELECT notes, status FROM public.journal_entries WHERE id = $1`, + [entryId], + ) + expect(after.rows[0]!.notes).toBe('Makulerad pga dubbelbokning') + expect(after.rows[0]!.status).toBe('reversed') + }) + + it('still blocks editing a bookkeeping field on a posted entry', async () => { + const { userId, companyId, fiscalPeriodId } = await seedCompany() + const entryId = await insertPostedEntry({ userId, companyId, fiscalPeriodId, voucherNumber: 1 }) + + await expect( + getPool().query( + `UPDATE public.journal_entries SET description = 'tampered' WHERE id = $1`, + [entryId], + ), + ).rejects.toThrow(/Cannot modify a posted journal entry/i) + }) + + // Defense in depth: a real bookkeeping change must not slip through just + // because `notes` also changed in the same UPDATE. The to_jsonb diff sees + // the entry_date change and the whole statement is rejected. + it('blocks a notes edit bundled with a bookkeeping field change', async () => { + const { userId, companyId, fiscalPeriodId } = await seedCompany() + const entryId = await insertPostedEntry({ userId, companyId, fiscalPeriodId, voucherNumber: 1 }) + + await expect( + getPool().query( + `UPDATE public.journal_entries + SET notes = 'looks innocent', entry_date = '2026-07-01' + WHERE id = $1`, + [entryId], + ), + ).rejects.toThrow(/Cannot modify a posted journal entry/i) + + const after = await getPool().query<{ notes: string | null; entry_date: string }>( + `SELECT notes, entry_date::text FROM public.journal_entries WHERE id = $1`, + [entryId], + ) + expect(after.rows[0]!.notes).toBeNull() + expect(after.rows[0]!.entry_date).toBe('2026-06-01') + }) + + // Scope guard: notes carve-out does NOT override the period lock. Editing + // notes on a committed entry in a locked period is still rejected by + // enforce_period_lock (which fires after this trigger). + it('still blocks notes edits when the fiscal period is locked', async () => { + const { userId, companyId, fiscalPeriodId } = await seedCompany() + const entryId = await insertPostedEntry({ userId, companyId, fiscalPeriodId, voucherNumber: 1 }) + await getPool().query( + `UPDATE public.fiscal_periods SET locked_at = now() WHERE id = $1`, + [fiscalPeriodId], + ) + + await expect( + getPool().query( + `UPDATE public.journal_entries SET notes = 'too late' WHERE id = $1`, + [entryId], + ), + ).rejects.toThrow(/locked\/closed fiscal period/i) + }) +}) diff --git a/lib/reconciliation/__tests__/bank-reconciliation.test.ts b/lib/reconciliation/__tests__/bank-reconciliation.test.ts index ccb978aa..48fcc582 100644 --- a/lib/reconciliation/__tests__/bank-reconciliation.test.ts +++ b/lib/reconciliation/__tests__/bank-reconciliation.test.ts @@ -11,6 +11,7 @@ import { manualLink, unlinkReconciliation, getReconciliationStatus, + scopeTransactionsToAccount, } from '../bank-reconciliation' import type { UnlinkedGLLine } from '../bank-reconciliation' import { makeTransaction } from '@/tests/helpers' @@ -38,6 +39,67 @@ function makeGLLine(overrides: Partial = {}): UnlinkedGLLine { } } +// ============================================================ +// scopeTransactionsToAccount — the per-account query filter +// ============================================================ + +describe('scopeTransactionsToAccount', () => { + // Records every filter call and returns itself so the chain can continue. + function makeQueryStub() { + const calls: { method: string; args: unknown[] }[] = [] + const self = { + eq: (...args: unknown[]) => { + calls.push({ method: 'eq', args }) + return self + }, + or: (...args: unknown[]) => { + calls.push({ method: 'or', args }) + return self + }, + } + return { self, calls } + } + + it('scopes by currency AND (this account OR legacy NULL) using a flat two-term or', () => { + const { self, calls } = makeQueryStub() + const id = '11111111-1111-1111-1111-111111111111' + + scopeTransactionsToAccount(self as never, id, 'SEK') + + // currency is constrained even on the bound branch (a cash account has one + // currency), which lets us avoid the fragile nested and() form. + expect(calls).toContainEqual({ method: 'eq', args: ['currency', 'SEK'] }) + expect(calls).toContainEqual({ + method: 'or', + args: [`cash_account_id.eq.${id},cash_account_id.is.null`], + }) + // Regression guard: the old nested `and(cash_account_id.is.null,currency.eq.X)` + // silently returned ZERO rows mid-backfill — it must never come back. + const orCall = calls.find((c) => c.method === 'or') + expect(String(orCall?.args[0])).not.toContain('and(') + }) + + it('falls back to a pure currency filter when no cash account id is given', () => { + const { self, calls } = makeQueryStub() + + scopeTransactionsToAccount(self as never, undefined, 'EUR') + + expect(calls).toEqual([{ method: 'eq', args: ['currency', 'EUR'] }]) + }) + + it('rejects a non-ISO currency (PostgREST filter-injection guard)', () => { + const { self } = makeQueryStub() + expect(() => + scopeTransactionsToAccount(self as never, undefined, 'SEK; drop' as never), + ).toThrow() + }) + + it('rejects a non-uuid cash account id', () => { + const { self } = makeQueryStub() + expect(() => scopeTransactionsToAccount(self as never, 'not-a-uuid', 'SEK')).toThrow() + }) +}) + // ============================================================ // tryReconcileTransaction — in-memory matching // ============================================================ @@ -424,7 +486,7 @@ describe('manualLink', () => { const result = await manualLink(supabase as never, 'company-1', 'tx-1', 'je-1', 'user-1') expect(result.success).toBe(false) - expect(result.error).toBe('Transaction not found') + expect(result.error).toBe('Transaktionen kunde inte hittas.') }) it('rejects when transaction is already linked', async () => { @@ -437,7 +499,7 @@ describe('manualLink', () => { const result = await manualLink(supabase as never, 'company-1', 'tx-1', 'je-1', 'user-1') expect(result.success).toBe(false) - expect(result.error).toBe('Transaction is already linked to a journal entry') + expect(result.error).toBe('Transaktionen är redan kopplad till en verifikation.') }) it('rejects when journal entry has no line on the selected account', async () => { diff --git a/lib/reconciliation/bank-reconciliation.ts b/lib/reconciliation/bank-reconciliation.ts index 1ef166c1..eb0040f4 100644 --- a/lib/reconciliation/bank-reconciliation.ts +++ b/lib/reconciliation/bank-reconciliation.ts @@ -94,13 +94,24 @@ export interface ReconciliationOptions { /** * Scope a transactions query builder to a single cash account, tolerating - * legacy rows that predate the cash_account_id backfill: - * cash_account_id = X OR (cash_account_id IS NULL AND currency = cur) - * A bound row shows only on its own account; an unbound row falls back to - * currency so nothing disappears mid-backfill. When cashAccountId is omitted - * we keep the pure currency filter (back-compat). + * legacy rows that predate the cash_account_id backfill. A bound row shows only + * on its own account; an unbound (NULL) row falls back to currency so nothing + * disappears mid-backfill. When cashAccountId is omitted we keep the pure + * currency filter (back-compat). + * + * The applied filter is: + * currency = cur AND (cash_account_id = X OR cash_account_id IS NULL) + * + * Earlier this used a single nested `or(cash_account_id.eq.X,and(cash_account_id.is.null,currency.eq.cur))`. + * That nested `and()` form is fragile — it silently returned ZERO rows for + * companies whose transactions were NULL/mis-assigned mid-backfill (issue: bank + * transactions vanished from Bankavstämning while the 1930 GL movement still + * showed). A cash account has exactly one currency (the `cash_accounts` + * (company_id, ledger_account) uniqueness assumption), so constraining the bound + * branch to that currency too loses nothing and lets us use the flat, reliable + * two-term `or` instead. */ -function scopeTransactionsToAccount(query: Q, cashAccountId: string | undefined, currency: string): Q { @@ -115,9 +126,9 @@ function scopeTransactionsToAccount1 bank-class line, e.g. 1930 → 1931) are ambiguous +-- and left untouched, exactly as the original backfill skipped them. +-- ------------------------------------------------------------ +UPDATE public.transactions t +SET cash_account_id = ca.id +FROM public.journal_entry_lines jel +JOIN public.cash_accounts ca + ON ca.ledger_account = jel.account_number +WHERE t.journal_entry_id IS NOT NULL + AND jel.journal_entry_id = t.journal_entry_id + AND ca.company_id = t.company_id + AND jel.account_number BETWEEN '1900' AND '1999' + AND t.cash_account_id IS DISTINCT FROM ca.id + AND ( + SELECT count(*) + FROM public.journal_entry_lines x + WHERE x.journal_entry_id = t.journal_entry_id + AND x.account_number BETWEEN '1900' AND '1999' + ) = 1; + +-- ------------------------------------------------------------ +-- 2. Deterministic repair for SINGLE-account-of-currency companies. +-- When a company has exactly one ENABLED cash account in a given currency, +-- every transaction of that currency unambiguously belongs to it — whether +-- the row is currently NULL or was mis-assigned. This fixes the entire +-- single-bank-account majority (the common enskild firma / aktiebolag with +-- one 1930 SEK account) in one shot. +-- +-- Companies with two same-currency accounts (e.g. checking + savings) are +-- excluded (HAVING count(*) = 1): we must not guess between them. Their +-- booked rows are already corrected by pass 1; their unbooked rows keep +-- whatever they had and rely on the query-time currency fallback. +-- ------------------------------------------------------------ +WITH single_ca AS ( + SELECT company_id, currency, (array_agg(id))[1] AS cash_account_id + FROM public.cash_accounts + WHERE enabled = true + GROUP BY company_id, currency + HAVING count(*) = 1 +) +UPDATE public.transactions t +SET cash_account_id = s.cash_account_id +FROM single_ca s +WHERE s.company_id = t.company_id + AND s.currency = t.currency + AND t.cash_account_id IS DISTINCT FROM s.cash_account_id; + +-- Pass 3 — anything still NULL (multi-same-currency-account companies' unbooked +-- rows) is left as-is; the query-time currency fallback in +-- scopeTransactionsToAccount() covers it. + +NOTIFY pgrst, 'reload schema'; diff --git a/tests/pg/transactions-cash-account-id.pg.test.ts b/tests/pg/transactions-cash-account-id.pg.test.ts index adf5c298..81319cca 100644 --- a/tests/pg/transactions-cash-account-id.pg.test.ts +++ b/tests/pg/transactions-cash-account-id.pg.test.ts @@ -39,6 +39,20 @@ async function runBackfill(): Promise { await getPool().query(BACKFILL_SQL) } +// The repair migration re-derives cash_account_id and CORRECTS mis-assignments +// (the NULL-only original backfill could not). Run the real SQL so the test +// exercises exactly what ships. +const REPAIR_SQL = readFileSync( + join( + process.cwd(), + 'supabase/migrations/20260609120000_transactions_cash_account_id_repair_backfill.sql', + ), + 'utf8', +) +async function runRepair(): Promise { + await getPool().query(REPAIR_SQL) +} + // Insert a journal entry (draft) with the given bank-class line account // numbers. One line per account at amount 100 (debit). Balance isn't required // for a draft entry — the balance trigger only fires on draft→posted. @@ -228,6 +242,105 @@ describe('transactions.cash_account_id — account-scoped query isolation', () = }) }) +describe('transactions.cash_account_id — repair backfill (20260609120000)', () => { + it('re-seeds a default 1930 SEK cash account for a company that has none', async () => { + const { companyId } = await seedCompany() + // seedCompany inserts the company directly (no cash account). + + await runRepair() + + const { rows } = await getPool().query( + `SELECT ledger_account, currency, is_primary + FROM public.cash_accounts WHERE company_id = $1`, + [companyId], + ) + expect(rows).toHaveLength(1) + expect(rows[0].ledger_account).toBe('1930') + expect(rows[0].currency).toBe('SEK') + expect(rows[0].is_primary).toBe(true) + }) + + it('CORRECTS a booked row mis-assigned to the wrong cash account', async () => { + const { userId, companyId, fiscalPeriodId } = await seedCompany() + const ca1930 = await insertCashAccount({ companyId, ledgerAccount: '1930', currency: 'SEK' }) + const ca1931 = await insertCashAccount({ companyId, ledgerAccount: '1931', currency: 'SEK' }) + + // The voucher settled on 1930, but the row was wrongly bound to 1931 — the + // exact mis-assignment the NULL-only original backfill can never undo. + const je = await insertEntryWithBankLines({ + userId, + companyId, + fiscalPeriodId, + bankAccounts: ['1930'], + }) + const tx = await insertTransaction({ companyId, userId, journalEntryId: je, cashAccountId: ca1931 }) + + await runRepair() + + expect(await getCashAccountId(tx)).toBe(ca1930) + }) + + it('CORRECTS a mis-assigned unbooked row in a single-SEK-account company', async () => { + // The headline Arcim regression: one SEK account; a buggy backfill bound a + // SEK row to the wrong account, so per-account scoping dropped it and + // Bankavstämning showed 0 transactions. Repair rebinds it to the sole SEK account. + const { userId, companyId } = await seedCompany() + const caSek = await insertCashAccount({ companyId, ledgerAccount: '1930', currency: 'SEK' }) + const caEur = await insertCashAccount({ companyId, ledgerAccount: '1932', currency: 'EUR' }) + const tx = await insertTransaction({ companyId, userId, currency: 'SEK', cashAccountId: caEur }) + + await runRepair() + + expect(await getCashAccountId(tx)).toBe(caSek) + }) + + it('binds NULL unbooked rows to the single account of their currency', async () => { + const { userId, companyId } = await seedCompany() + const ca = await insertCashAccount({ companyId, ledgerAccount: '1930', currency: 'SEK' }) + const tx = await insertTransaction({ companyId, userId, currency: 'SEK' }) + + await runRepair() + + expect(await getCashAccountId(tx)).toBe(ca) + }) + + it('does NOT touch an unbooked row when two same-currency accounts exist', async () => { + const { userId, companyId } = await seedCompany() + await insertCashAccount({ companyId, ledgerAccount: '1930', currency: 'SEK' }) + await insertCashAccount({ companyId, ledgerAccount: '1931', currency: 'SEK' }) + const tx = await insertTransaction({ companyId, userId, currency: 'SEK' }) + + await runRepair() + + expect(await getCashAccountId(tx)).toBeNull() + }) + + it('is idempotent — a second run changes nothing', async () => { + const { userId, companyId } = await seedCompany() + const ca = await insertCashAccount({ companyId, ledgerAccount: '1930', currency: 'SEK' }) + const tx = await insertTransaction({ companyId, userId, currency: 'SEK' }) + + await runRepair() + const first = await getCashAccountId(tx) + await runRepair() + + expect(first).toBe(ca) + expect(await getCashAccountId(tx)).toBe(ca) + }) + + it('never rebinds across companies', async () => { + const a = await seedCompany() + const b = await seedCompany() + const caA = await insertCashAccount({ companyId: a.companyId, ledgerAccount: '1930', currency: 'SEK' }) + await insertCashAccount({ companyId: b.companyId, ledgerAccount: '1930', currency: 'SEK' }) + const txA = await insertTransaction({ companyId: a.companyId, userId: a.userId, currency: 'SEK' }) + + await runRepair() + + expect(await getCashAccountId(txA)).toBe(caA) + }) +}) + describe('transactions.cash_account_id — cross-company isolation', () => { it('backfill never binds a transaction to another company\'s cash account', async () => { const a = await seedCompany()