fix(reconciliation): surface bank tx + match to existing verifikat, delete UX, DPA links (#623)
* 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
953980c875
commit
b5c3c3ec04
@@ -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() {
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* Privacy & agreements — surface the otherwise-unlinked DPA + privacy policy */}
|
||||
<section className="border-t border-border/8 pt-8">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{tSettings('legal_title')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<Link
|
||||
href="/privacy"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-between rounded-lg border p-4 transition-colors hover:bg-secondary/60"
|
||||
>
|
||||
<span className="font-medium">{tSettings('legal_privacy')}</span>
|
||||
<ExternalLink className="h-4 w-4 text-muted-foreground" />
|
||||
</Link>
|
||||
<Link
|
||||
href="/dpa"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-between rounded-lg border p-4 transition-colors hover:bg-secondary/60"
|
||||
>
|
||||
<span className="font-medium">{tSettings('legal_dpa')}</span>
|
||||
<ExternalLink className="h-4 w-4 text-muted-foreground" />
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* Delete account — only for non-sandbox */}
|
||||
{!settings?.is_sandbox && <AccountDangerZone />}
|
||||
</div>
|
||||
|
||||
@@ -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<TransactionWithInvoice | null>(null)
|
||||
const [splitMatchOpen, setSplitMatchOpen] = useState(false)
|
||||
const [splitMatchTransaction, setSplitMatchTransaction] = useState<TransactionWithInvoice | null>(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<TransactionWithInvoice | null>(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<boolean> {
|
||||
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}
|
||||
/>
|
||||
|
||||
<MatchVoucherDialog
|
||||
open={matchVoucherTx !== null}
|
||||
onOpenChange={(o) => { if (!o) setMatchVoucherTx(null) }}
|
||||
transaction={matchVoucherTx}
|
||||
onLinked={handleVoucherLinked}
|
||||
/>
|
||||
|
||||
<MatchAllocationDialog
|
||||
open={splitMatchOpen}
|
||||
onOpenChange={(o) => {
|
||||
|
||||
@@ -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).
|
||||
</p>
|
||||
<p>
|
||||
Behandlar du själv personuppgifter åt andra (kunder, leverantörer, anställda)? Se vårt
|
||||
fullständiga{' '}
|
||||
<Link href="/dpa" className="text-primary underline underline-offset-4">
|
||||
personuppgiftsbiträdesavtal (DPA)
|
||||
</Link>{' '}
|
||||
enligt GDPR Art. 28.
|
||||
</p>
|
||||
|
||||
<div className="overflow-x-auto mt-4">
|
||||
<table className="min-w-full text-sm">
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 <select> couldn't be searched, and the unmatched-GL list
|
||||
* routinely runs to hundreds of rows (historical SIE imports), so the old UX
|
||||
* forced users to scroll a giant unsorted dropdown. This picker filters by
|
||||
* voucher number, date, amount or description as the user types, and renders
|
||||
* the selected verifikation as a removable chip.
|
||||
*
|
||||
* Extracted from BankReconciliationView so the Transactions-page
|
||||
* MatchVoucherDialog can reuse the exact same picker.
|
||||
*/
|
||||
export function MatchVerifikationPicker({
|
||||
glLines,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
placeholder = 'Sök ver.nr, datum, belopp eller beskrivning…',
|
||||
}: MatchPickerProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [search, setSearch] = useState('')
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
function onDocMouseDown(e: MouseEvent) {
|
||||
if (!containerRef.current?.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', onDocMouseDown)
|
||||
return () => document.removeEventListener('mousedown', onDocMouseDown)
|
||||
}, [open])
|
||||
|
||||
const selected = glLines.find((l) => l.journal_entry_id === value) || null
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase()
|
||||
const base = q.length === 0
|
||||
? glLines
|
||||
: glLines.filter((line) => {
|
||||
const amt = (line.debit_amount > 0 ? line.debit_amount : line.credit_amount).toString()
|
||||
return (
|
||||
formatVoucher(line).toLowerCase().includes(q) ||
|
||||
line.entry_date.toLowerCase().includes(q) ||
|
||||
amt.includes(q) ||
|
||||
(line.entry_description || '').toLowerCase().includes(q) ||
|
||||
(line.line_description || '').toLowerCase().includes(q)
|
||||
)
|
||||
})
|
||||
return base.slice(0, 25)
|
||||
}, [search, glLines])
|
||||
|
||||
if (selected) {
|
||||
const amount = selected.debit_amount > 0 ? selected.debit_amount : -selected.credit_amount
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-border bg-secondary/40 px-3 py-2 text-sm">
|
||||
<span className="font-mono text-xs shrink-0">{formatVoucher(selected)}</span>
|
||||
<span className="text-muted-foreground shrink-0 tabular-nums">{formatDate(selected.entry_date)}</span>
|
||||
<span className="font-mono tabular-nums shrink-0">{formatCurrency(amount)}</span>
|
||||
<span className="truncate text-muted-foreground">{selected.entry_description}</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="ml-auto h-6 w-6 shrink-0"
|
||||
onClick={() => onChange('')}
|
||||
disabled={disabled}
|
||||
aria-label="Avmarkera verifikation"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground pointer-events-none" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value)
|
||||
setOpen(true)
|
||||
}}
|
||||
onFocus={() => setOpen(true)}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
{open && (
|
||||
<div className="absolute z-20 mt-1 w-full overflow-hidden rounded-lg border border-border bg-popover shadow-[var(--shadow-md)]">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="px-3 py-4 text-sm text-muted-foreground text-center">
|
||||
Inga verifikationer matchar "{search}"
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-72 overflow-y-auto">
|
||||
{filtered.map((line) => {
|
||||
const amount = line.debit_amount > 0 ? line.debit_amount : -line.credit_amount
|
||||
return (
|
||||
<button
|
||||
key={line.line_id}
|
||||
type="button"
|
||||
className="flex w-full items-center gap-3 px-3 py-2 text-left text-sm transition-colors hover:bg-secondary/60 focus:bg-secondary/60 focus:outline-none"
|
||||
onMouseDown={(e) => {
|
||||
// mousedown beats blur — without this the popover closes
|
||||
// before the click registers when the user has tabbed
|
||||
// through and uses keyboard.
|
||||
e.preventDefault()
|
||||
}}
|
||||
onClick={() => {
|
||||
onChange(line.journal_entry_id)
|
||||
setSearch('')
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
<span className="font-mono text-xs shrink-0 w-12">{formatVoucher(line)}</span>
|
||||
<span className="text-muted-foreground shrink-0 tabular-nums w-24">{formatDate(line.entry_date)}</span>
|
||||
<span className="font-mono tabular-nums shrink-0 w-24 text-right">{formatCurrency(amount)}</span>
|
||||
<span className="truncate text-muted-foreground flex-1">
|
||||
{line.line_description || line.entry_description}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{glLines.length > filtered.length && (
|
||||
<div className="px-3 py-2 text-[11px] text-muted-foreground border-t border-border bg-secondary/30">
|
||||
Visar {filtered.length} av {glLines.length} — sök för att filtrera fler.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 <select> couldn't be searched, and the unmatched-GL list
|
||||
* routinely runs to hundreds of rows (historical SIE imports), so the old UX
|
||||
* forced users to scroll a giant unsorted dropdown. This picker filters by
|
||||
* voucher number, date, amount or description as the user types, and renders
|
||||
* the selected verifikation as a removable chip.
|
||||
*/
|
||||
interface MatchPickerProps {
|
||||
glLines: UnlinkedGLLine[]
|
||||
value: string
|
||||
onChange: (journalEntryId: string) => void
|
||||
disabled?: boolean
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
function MatchVerifikationPicker({
|
||||
glLines,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
placeholder = 'Sök ver.nr, datum, belopp eller beskrivning…',
|
||||
}: MatchPickerProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [search, setSearch] = useState('')
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
function onDocMouseDown(e: MouseEvent) {
|
||||
if (!containerRef.current?.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', onDocMouseDown)
|
||||
return () => document.removeEventListener('mousedown', onDocMouseDown)
|
||||
}, [open])
|
||||
|
||||
const selected = glLines.find((l) => l.journal_entry_id === value) || null
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase()
|
||||
const base = q.length === 0
|
||||
? glLines
|
||||
: glLines.filter((line) => {
|
||||
const amt = (line.debit_amount > 0 ? line.debit_amount : line.credit_amount).toString()
|
||||
return (
|
||||
formatVoucher(line).toLowerCase().includes(q) ||
|
||||
line.entry_date.toLowerCase().includes(q) ||
|
||||
amt.includes(q) ||
|
||||
(line.entry_description || '').toLowerCase().includes(q) ||
|
||||
(line.line_description || '').toLowerCase().includes(q)
|
||||
)
|
||||
})
|
||||
return base.slice(0, 25)
|
||||
}, [search, glLines])
|
||||
|
||||
if (selected) {
|
||||
const amount = selected.debit_amount > 0 ? selected.debit_amount : -selected.credit_amount
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-border bg-secondary/40 px-3 py-2 text-sm">
|
||||
<span className="font-mono text-xs shrink-0">{formatVoucher(selected)}</span>
|
||||
<span className="text-muted-foreground shrink-0 tabular-nums">{formatDate(selected.entry_date)}</span>
|
||||
<span className="font-mono tabular-nums shrink-0">{formatCurrency(amount)}</span>
|
||||
<span className="truncate text-muted-foreground">{selected.entry_description}</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="ml-auto h-6 w-6 shrink-0"
|
||||
onClick={() => onChange('')}
|
||||
disabled={disabled}
|
||||
aria-label="Avmarkera verifikation"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground pointer-events-none" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value)
|
||||
setOpen(true)
|
||||
}}
|
||||
onFocus={() => setOpen(true)}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
{open && (
|
||||
<div className="absolute z-20 mt-1 w-full overflow-hidden rounded-lg border border-border bg-popover shadow-[var(--shadow-md)]">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="px-3 py-4 text-sm text-muted-foreground text-center">
|
||||
Inga verifikationer matchar "{search}"
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-72 overflow-y-auto">
|
||||
{filtered.map((line) => {
|
||||
const amount = line.debit_amount > 0 ? line.debit_amount : -line.credit_amount
|
||||
return (
|
||||
<button
|
||||
key={line.line_id}
|
||||
type="button"
|
||||
className="flex w-full items-center gap-3 px-3 py-2 text-left text-sm transition-colors hover:bg-secondary/60 focus:bg-secondary/60 focus:outline-none"
|
||||
onMouseDown={(e) => {
|
||||
// mousedown beats blur — without this the popover closes
|
||||
// before the click registers when the user has tabbed
|
||||
// through and uses keyboard.
|
||||
e.preventDefault()
|
||||
}}
|
||||
onClick={() => {
|
||||
onChange(line.journal_entry_id)
|
||||
setSearch('')
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
<span className="font-mono text-xs shrink-0 w-12">{formatVoucher(line)}</span>
|
||||
<span className="text-muted-foreground shrink-0 tabular-nums w-24">{formatDate(line.entry_date)}</span>
|
||||
<span className="font-mono tabular-nums shrink-0 w-24 text-right">{formatCurrency(amount)}</span>
|
||||
<span className="truncate text-muted-foreground flex-1">
|
||||
{line.line_description || line.entry_description}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{glLines.length > filtered.length && (
|
||||
<div className="px-3 py-2 text-[11px] text-muted-foreground border-t border-border bg-secondary/30">
|
||||
Visar {filtered.length} av {glLines.length} — sök för att filtrera fler.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Component
|
||||
// ============================================================
|
||||
@@ -286,10 +126,26 @@ export function BankReconciliationView() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(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<CashAccount[]>([])
|
||||
// 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<DryRunMatch[] | null>(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()
|
||||
|
||||
@@ -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<UnlinkedGLLine[]>([])
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Matcha mot befintlig verifikation</DialogTitle>
|
||||
<DialogDescription>
|
||||
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.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Transaction summary */}
|
||||
<div className="flex items-center gap-3 rounded-lg border border-border bg-secondary/30 px-3 py-2.5 text-sm">
|
||||
<span
|
||||
className={isIncome ? 'text-success' : 'text-foreground/60'}
|
||||
aria-hidden
|
||||
>
|
||||
{isIncome ? <ArrowUpRight className="h-4 w-4" /> : <ArrowDownRight className="h-4 w-4" />}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-medium">{transaction.description}</p>
|
||||
<p className="text-xs text-muted-foreground tabular-nums">{formatDate(transaction.date)}</p>
|
||||
</div>
|
||||
<span className={`font-medium tabular-nums ${isIncome ? 'text-success' : ''}`}>
|
||||
{isIncome ? '+' : ''}
|
||||
{formatCurrency(transaction.amount, transaction.currency)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{accountFallback && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Avstämning mot 1930. Hör transaktionen till ett annat bankkonto? Stäm av
|
||||
det under Rapporter → Bankavstämning.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Candidate picker */}
|
||||
<div className="space-y-2">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center gap-2 rounded-lg border border-border py-6 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Söker verifikationer…
|
||||
</div>
|
||||
) : glLines.length === 0 ? (
|
||||
<div className="rounded-lg border border-border px-3 py-6 text-center text-sm text-muted-foreground">
|
||||
<p>Inga omatchade verifikationer på {accountNumber} i perioden.</p>
|
||||
{!wideRange && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-3"
|
||||
onClick={() => setWideRange(true)}
|
||||
>
|
||||
Visa alla datum
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{selectedLine && (selectedLine.confidence ?? 0) >= 0.85 && (
|
||||
<Badge variant="success" className="mb-1">Föreslagen träff</Badge>
|
||||
)}
|
||||
<MatchVerifikationPicker glLines={glLines} value={selected} onChange={setSelected} />
|
||||
{!wideRange && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-muted-foreground underline underline-offset-2 hover:text-foreground"
|
||||
onClick={() => setWideRange(true)}
|
||||
>
|
||||
Hittar du inte verifikationen? Visa alla datum
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={submitting}>
|
||||
Avbryt
|
||||
</Button>
|
||||
<Button onClick={handleConfirm} disabled={!selected || submitting}>
|
||||
{submitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Kopplar…
|
||||
</>
|
||||
) : (
|
||||
'Koppla'
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<motion.div
|
||||
layout
|
||||
@@ -276,22 +301,6 @@ export default function TransactionInboxCard({
|
||||
<Link2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{showInvoiceMatchButton && onOpenSplitMatch && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-9 w-9"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onOpenSplitMatch(transaction)
|
||||
}}
|
||||
aria-label={splitMatchLabel}
|
||||
title={splitMatchLabel}
|
||||
disabled={isProcessing || isDisabled}
|
||||
>
|
||||
<Split className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{/* 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 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9 text-muted-foreground hover:text-foreground"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onEditTitle(transaction)
|
||||
}}
|
||||
aria-label={t('edit_title_aria')}
|
||||
title={t('edit_title_aria')}
|
||||
disabled={isProcessing || isDisabled}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{isDeletable && onDelete && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9 text-muted-foreground hover:text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDelete(transaction.id)
|
||||
}}
|
||||
aria-label={t('delete_aria')}
|
||||
disabled={isProcessing || isDisabled}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
{/* Secondary actions (split, edit, delete) collapse into a ⋯
|
||||
overflow menu so the row stays uncluttered. */}
|
||||
{showOverflowMenu && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9 text-muted-foreground hover:text-foreground"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label={t('more_actions_aria')}
|
||||
title={t('more_actions_aria')}
|
||||
disabled={isProcessing || isDisabled}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-[14rem]">
|
||||
{showMatchVoucherItem && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onOpenMatchVoucher!(transaction)
|
||||
}}
|
||||
>
|
||||
<FileSearch className="h-4 w-4" />
|
||||
{t('match_voucher_btn')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{showSplitItem && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onOpenSplitMatch!(transaction)
|
||||
}}
|
||||
>
|
||||
<Split className="h-4 w-4" />
|
||||
{splitMatchLabel}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{showEditItem && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onEditTitle!(transaction)
|
||||
}}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
{t('edit_title_aria')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{showDeleteItem && (
|
||||
<>
|
||||
{(showMatchVoucherItem || showSplitItem || showEditItem) && <DropdownMenuSeparator />}
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDelete!(transaction.id)
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
{t('delete_aria')}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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<string> {
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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> = {}): 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 () => {
|
||||
|
||||
@@ -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<Q extends {
|
||||
export function scopeTransactionsToAccount<Q extends {
|
||||
or(filters: string): Q
|
||||
eq(column: string, value: string): Q
|
||||
}>(query: Q, cashAccountId: string | undefined, currency: string): Q {
|
||||
@@ -115,9 +126,9 @@ function scopeTransactionsToAccount<Q extends {
|
||||
if (!/^[0-9a-fA-F-]{36}$/.test(cashAccountId)) {
|
||||
throw new Error('scopeTransactionsToAccount: invalid cashAccountId (expected UUID)')
|
||||
}
|
||||
return query.or(
|
||||
`cash_account_id.eq.${cashAccountId},and(cash_account_id.is.null,currency.eq.${currency})`,
|
||||
)
|
||||
return query
|
||||
.eq('currency', currency)
|
||||
.or(`cash_account_id.eq.${cashAccountId},cash_account_id.is.null`)
|
||||
}
|
||||
return query.eq('currency', currency)
|
||||
}
|
||||
@@ -452,11 +463,11 @@ export async function manualLink(
|
||||
.single()
|
||||
|
||||
if (txError || !tx) {
|
||||
return { success: false, error: 'Transaction not found' }
|
||||
return { success: false, error: 'Transaktionen kunde inte hittas.' }
|
||||
}
|
||||
|
||||
if (tx.journal_entry_id) {
|
||||
return { success: false, error: 'Transaction is already linked to a journal entry' }
|
||||
return { success: false, error: 'Transaktionen är redan kopplad till en verifikation.' }
|
||||
}
|
||||
|
||||
// Fetch journal entry + verify it has a 1930 line
|
||||
@@ -468,11 +479,11 @@ export async function manualLink(
|
||||
.single()
|
||||
|
||||
if (entryError || !entry) {
|
||||
return { success: false, error: 'Journal entry not found' }
|
||||
return { success: false, error: 'Verifikationen kunde inte hittas.' }
|
||||
}
|
||||
|
||||
if (entry.status !== 'posted') {
|
||||
return { success: false, error: 'Journal entry is not posted' }
|
||||
return { success: false, error: 'Verifikationen är inte bokförd ännu.' }
|
||||
}
|
||||
|
||||
// Defense-in-depth: the transaction must belong to the account being
|
||||
@@ -516,7 +527,7 @@ export async function manualLink(
|
||||
.single()
|
||||
|
||||
if (existingLink) {
|
||||
return { success: false, error: 'Another transaction is already linked to this journal entry' }
|
||||
return { success: false, error: 'En annan transaktion är redan kopplad till den här verifikationen.' }
|
||||
}
|
||||
|
||||
// Apply link
|
||||
@@ -531,7 +542,7 @@ export async function manualLink(
|
||||
.eq('company_id', companyId)
|
||||
|
||||
if (updateError) {
|
||||
return { success: false, error: 'Failed to link transaction' }
|
||||
return { success: false, error: 'Kunde inte koppla transaktionen. Försök igen.' }
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
+6
-1
@@ -183,7 +183,10 @@
|
||||
"section_language": "Language",
|
||||
"language_description": "Choose the language for the interface. Invoices and reports always stay in Swedish.",
|
||||
"language_saved": "Language updated",
|
||||
"language_save_failed": "Could not save language preference"
|
||||
"language_save_failed": "Could not save language preference",
|
||||
"legal_title": "Privacy & agreements",
|
||||
"legal_privacy": "Privacy policy",
|
||||
"legal_dpa": "Data Processing Agreement (DPA)"
|
||||
},
|
||||
"help": {
|
||||
"title": "Help & Glossary",
|
||||
@@ -1633,6 +1636,8 @@
|
||||
"match_invoice_btn": "Match invoice {number}",
|
||||
"match_supplier_invoice_btn": "Match supplier invoice {number}",
|
||||
"choose_template_btn": "Choose template...",
|
||||
"match_voucher_btn": "Match to existing voucher",
|
||||
"more_actions_aria": "More actions",
|
||||
"delete_aria": "Delete transaction",
|
||||
"edit_title_aria": "Edit title",
|
||||
"edited_badge": "edited",
|
||||
|
||||
+6
-1
@@ -183,7 +183,10 @@
|
||||
"section_language": "Språk",
|
||||
"language_description": "Välj språket för gränssnittet. Fakturor och rapporter förblir alltid på svenska.",
|
||||
"language_saved": "Språket har uppdaterats",
|
||||
"language_save_failed": "Kunde inte spara språkinställningen"
|
||||
"language_save_failed": "Kunde inte spara språkinställningen",
|
||||
"legal_title": "Sekretess och avtal",
|
||||
"legal_privacy": "Integritetspolicy",
|
||||
"legal_dpa": "Personuppgiftsbiträdesavtal (DPA)"
|
||||
},
|
||||
"help": {
|
||||
"title": "Hjälp & Ordlista",
|
||||
@@ -1633,6 +1636,8 @@
|
||||
"match_invoice_btn": "Matcha Faktura {number}",
|
||||
"match_supplier_invoice_btn": "Matcha Leverantörsfaktura {number}",
|
||||
"choose_template_btn": "Välj mall...",
|
||||
"match_voucher_btn": "Matcha mot befintlig verifikation",
|
||||
"more_actions_aria": "Fler åtgärder",
|
||||
"delete_aria": "Ta bort transaktion",
|
||||
"edit_title_aria": "Ändra titel",
|
||||
"edited_badge": "redigerad",
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
-- Fix: allow editing the internal `notes` field on a committed journal entry.
|
||||
--
|
||||
-- Bug: saving a note on a posted verifikation failed with
|
||||
-- "Cannot modify a posted journal entry ... Committed entries are immutable".
|
||||
-- The detail page (app/(dashboard)/bookkeeping/[id]/page.tsx) and the
|
||||
-- /api/bookkeeping/journal-entries/[id]/notes route treat `notes` as
|
||||
-- always-editable internal metadata, but enforce_journal_entry_immutability()
|
||||
-- only permitted draft→{draft,posted,cancelled}, posted→{reversed,cancelled}
|
||||
-- and the reversed→posted un-reversal. A note edit is a posted→posted UPDATE,
|
||||
-- which fell through to the final RAISE EXCEPTION.
|
||||
--
|
||||
-- Legal basis for the carve-out: `notes` is internal annotation metadata, NOT
|
||||
-- verifikation content under BFL 5 kap. / BFNAR 2013:2. Adding or editing a
|
||||
-- note leaves every bookkeeping field untouched — entry_date, description,
|
||||
-- accounts, amounts (the lines), voucher_series, voucher_number, status,
|
||||
-- source_* — so it does not alter the bokföringspost. The immutability
|
||||
-- guarantee for the verifikation itself is fully preserved: this path allows a
|
||||
-- change to `notes` ONLY, verified with a whole-row to_jsonb() diff. Any other
|
||||
-- field change on a committed entry still raises.
|
||||
--
|
||||
-- Scope notes:
|
||||
-- * Period lock is unchanged. enforce_period_lock fires after this trigger
|
||||
-- and still blocks notes edits on entries in a closed/locked period. Notes
|
||||
-- are editable on committed entries in OPEN periods only — the reported
|
||||
-- case (entry 9b97..., posted, period open).
|
||||
-- * This is a CREATE OR REPLACE in a NEW migration, the same pattern used by
|
||||
-- 20260428160000_fix_journal_entry_immutability_delete_bypass.sql. The
|
||||
-- legally-required protections in migration 017 are extended, never weakened.
|
||||
-- * Restores `SET search_path = public`, which the 20260428160000
|
||||
-- CREATE OR REPLACE silently dropped (the security pin added by
|
||||
-- 20260304191528_set_search_path_on_functions.sql).
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.enforce_journal_entry_immutability()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SET search_path = public
|
||||
AS $function$
|
||||
BEGIN
|
||||
IF TG_OP = 'DELETE' THEN
|
||||
IF current_setting('gnubok.allow_delete', true) = 'true' THEN
|
||||
RETURN OLD;
|
||||
END IF;
|
||||
RAISE EXCEPTION 'Cannot delete journal entries (id: %, status: %). Use cancelled status instead.',
|
||||
OLD.id, OLD.status;
|
||||
END IF;
|
||||
|
||||
IF OLD.status = 'draft' AND NEW.status IN ('draft', 'posted', 'cancelled') THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
IF OLD.status = 'posted' AND NEW.status IN ('reversed', 'cancelled') THEN
|
||||
IF NEW.status = 'reversed' THEN
|
||||
IF NEW.description != OLD.description OR NEW.entry_date != OLD.entry_date
|
||||
OR NEW.fiscal_period_id != OLD.fiscal_period_id
|
||||
OR NEW.voucher_number != OLD.voucher_number
|
||||
OR NEW.commit_method IS DISTINCT FROM OLD.commit_method
|
||||
OR NEW.rubric_version IS DISTINCT FROM OLD.rubric_version
|
||||
OR NEW.source_voucher_series IS DISTINCT FROM OLD.source_voucher_series
|
||||
OR NEW.source_voucher_number IS DISTINCT FROM OLD.source_voucher_number THEN
|
||||
RAISE EXCEPTION 'Cannot modify fields of a posted entry during reversal (id: %)', OLD.id;
|
||||
END IF;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
-- Narrow un-reversal path: when delete_last_voucher removes a storno entry,
|
||||
-- it flips the original from 'reversed' back to 'posted'. No other fields
|
||||
-- may change, and the bypass flag must be set.
|
||||
IF OLD.status = 'reversed' AND NEW.status = 'posted'
|
||||
AND current_setting('gnubok.allow_delete', true) = 'true' THEN
|
||||
IF NEW.description != OLD.description OR NEW.entry_date != OLD.entry_date
|
||||
OR NEW.fiscal_period_id != OLD.fiscal_period_id
|
||||
OR NEW.voucher_number != OLD.voucher_number THEN
|
||||
RAISE EXCEPTION 'Cannot modify fields during un-reversal (id: %)', OLD.id;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
-- Notes-only annotation on a committed entry (posted/reversed/cancelled).
|
||||
-- `notes` is internal metadata, not verifikation content, so editing it does
|
||||
-- not violate immutability. Allowed ONLY when the status is unchanged and the
|
||||
-- sole difference between OLD and NEW is `notes` (updated_at is exempt because
|
||||
-- the journal_entries_updated_at trigger bumps it). The to_jsonb() diff covers
|
||||
-- every other column automatically, so any real bookkeeping change still raises.
|
||||
IF OLD.status = NEW.status
|
||||
AND OLD.status IN ('posted', 'reversed', 'cancelled')
|
||||
AND (to_jsonb(NEW) - 'notes' - 'updated_at')
|
||||
= (to_jsonb(OLD) - 'notes' - 'updated_at') THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
RAISE EXCEPTION 'Cannot modify a % journal entry (id: %). Committed entries are immutable per Bokforingslagen.',
|
||||
OLD.status, OLD.id;
|
||||
END;
|
||||
$function$;
|
||||
@@ -0,0 +1,106 @@
|
||||
-- Migration: repair transactions.cash_account_id
|
||||
--
|
||||
-- Why this exists: the original best-effort backfill
|
||||
-- (20260606120100_transactions_cash_account_id_backfill.sql) only ever touched
|
||||
-- rows whose cash_account_id was still NULL, and an earlier revision of its
|
||||
-- single-account pass mis-assigned rows (the "min(uuid)" bug fixed in a later
|
||||
-- commit). Migrations run exactly once, so any instance that applied the buggy
|
||||
-- revision now carries WRONG (non-NULL) cash_account_id values that the
|
||||
-- NULL-only backfill can never correct.
|
||||
--
|
||||
-- The visible symptom: Bankavstämning (Reports → Bank reconciliation) showed
|
||||
-- "Banktransaktioner i perioden: 0 kr" while the 1930 GL movement and a large
|
||||
-- difference were still displayed — because the per-account scoping filtered
|
||||
-- every transaction out (its cash_account_id matched neither the resolved 1930
|
||||
-- account nor the NULL fallback), while the GL side (scoped only by account
|
||||
-- number) was unaffected.
|
||||
--
|
||||
-- This migration RE-DERIVES cash_account_id where it can do so deterministically,
|
||||
-- correcting mis-assignments rather than only filling NULLs. It is idempotent:
|
||||
-- every pass is a no-op once the data is already correct.
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- 0. Re-seed the default 1930 SEK cash account for any company missing one.
|
||||
-- Mirrors 20260519154732_seed_default_cash_account.sql so a company created
|
||||
-- through a path that skipped the seed (or before it existed) still has a
|
||||
-- routable account to scope against. Idempotent via the unique constraint.
|
||||
-- ------------------------------------------------------------
|
||||
INSERT INTO public.cash_accounts (
|
||||
company_id, ledger_account, currency, name, enabled, is_primary, source
|
||||
)
|
||||
SELECT
|
||||
c.id,
|
||||
'1930',
|
||||
'SEK',
|
||||
'Företagskonto (SEK)',
|
||||
true,
|
||||
NOT EXISTS (
|
||||
SELECT 1 FROM public.cash_accounts ca2
|
||||
WHERE ca2.company_id = c.id AND ca2.is_primary = true
|
||||
),
|
||||
'manual'
|
||||
FROM public.companies c
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM public.cash_accounts ca
|
||||
WHERE ca.company_id = c.id AND ca.ledger_account = '1930'
|
||||
)
|
||||
ON CONFLICT (company_id, ledger_account) DO NOTHING;
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- 1. Authoritative repair for BOOKED rows — from the voucher's single bank line.
|
||||
-- Unlike the original pass (a) this does NOT require cash_account_id IS NULL,
|
||||
-- so it CORRECTS a row that was mis-assigned. The voucher's 19xx line is the
|
||||
-- ground truth for where a booked transaction settled.
|
||||
--
|
||||
-- Own-account transfers (>1 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';
|
||||
@@ -39,6 +39,20 @@ async function runBackfill(): Promise<void> {
|
||||
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<void> {
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user