Files
accounted/components/transactions/BulkBookDialog.tsx
T
Jakob WennbergandClaude Opus 4.7 4da87e5e4c feat(transactions): bulk-book + is-booked predicate (#606)
* feat(transactions): bulk-book + is-booked predicate

Closes the second of the two multi-tx ↔ multi-voucher flows from the
original plan. Where PR #603's match_batch_allocate took 1 tx and
spread it across N invoices (samlingsbetalning), this PR takes N bank
transactions on the same day and rolls them up into ONE combined
verifikat (samlingsverifikation per BFL 5 kap 6§ st 3) — the kiosk
masshantering pattern the user explicitly asked for.

## Backend (Phase 3b)

- **PL/pgSQL RPC** bulk_book_transactions: two branches, both atomic.

  1. Link to existing posted verifikat (p_existing_journal_entry_id):
     no new JE. Validates the JE's 19xx net equals sum(tx.amount),
     inserts N transaction_voucher_links rows, and for N=1 also sets
     transactions.journal_entry_id (1:1 reader-path back-compat).

  2. Create new combined verifikat (p_new_entry with pre-computed
     balanced lines): the route's applyTemplate() has already done
     ratio + VAT expansion per the chosen mode. The RPC validates the
     lines balance and the 1930 net matches sum(tx.amount), then
     commits via commit_journal_entry.

  Same security pattern as match_batch_allocate: company-member check
  via auth.uid(), SELECT … FOR UPDATE on each tx in id order,
  deterministic fiscal-period resolution (ORDER BY period_start DESC).

- **Endpoint** POST /api/transactions/bulk-book — fetches template via
  RLS, expands per mode (one_line_per_tx | sum_per_account) using
  lib/bookkeeping/template-library.applyTemplate, passes the resulting
  lines to the RPC. On success emits one transaction.reconciled event
  per tx.

- **22 new BULK_BOOK_* error codes** (sv + en) covering all guard
  paths.

## UI (Phase 5b)

- **BulkBookDialog** — template picker + mode toggle (segmented
  control: en rad per transaktion / summera per konto) + live preview
  table with balance + bank-leg invariant indicators. Confirm only
  enabled when both pass.

- **Multi-select inbox** — sticky action bar gains a "Bokför i klump"
  button gated by same-date + same-direction across selected txs.
  Tooltip explains the disabled state.

## Phase 6: is-booked predicate

New lib/transactions/is-booked.ts. After multi-allocation and bulk-
book, tx.journal_entry_id can be NULL even though the tx is anchored
(via invoice_payments / supplier_invoice_payments /
transaction_voucher_links). The helper checks all three storage
locations so future readers don't falsely show multi-anchored txs as
"unbooked". Companion getPrimaryJournalEntryId() resolves the best
JE link to surface in UI. SQL mirror is_transaction_booked() exists
from the PR #602 foundation migration.

Existing readers (TransactionHistoryList, TransactionInboxCard) are
not yet refactored to use the helper — that's a follow-up that
touches per-tx JE links across multiple call sites. The helper is
documented + tested so subsequent refactors are mechanical.

## Tests

- tests/pg/bulk-book-transactions.pg.test.ts — 8 pg-real scenarios
  (happy path create-new with 3 txs, happy path link-existing, date
  mismatch, direction mismatch, amount mismatch, unbalanced lines,
  unauthorized).
- app/api/transactions/bulk-book/__tests__/route.test.ts — 5 unit
  tests (schema XOR, link path, create-new with template fetch +
  applyTemplate, structured-error mapping).
- lib/transactions/__tests__/is-booked.test.ts — 11 cases covering
  all three storage locations + primary-JE resolution.

26 unit tests pass on touched paths. RPC migration applied to remote
via Supabase MCP.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(bulk-book): PR #606 review round 1 + CI fixes

Closes the build failure and the two real Greptile findings.

## CI

- **core-only + Vercel build fail**: I used useMemo for
  selectedTransactions and bulkBookEligible on the transactions page
  without importing it. TypeScript build (`next build`) caught it
  with "Cannot find name 'useMemo'". Fixed the import.

## Review findings

- **(P1) Currency mismatch returned BULK_BOOK_DIRECTION_MISMATCH**
  whose user-facing message blames direction. Mixed SEK + EUR batches
  would show "All transactions must be the same direction" which is
  factually wrong. Introduced dedicated BULK_BOOK_MIXED_CURRENCY code
  (sv + en) explaining the actual constraint, and switched the route
  to use it.

- **(P1) Branch B (create-new) N=1 missed
  reconciliation_method='manual'**. Branch A's N=1 UPDATE sets it
  alongside journal_entry_id; Branch B's didn't, leaving the
  reconciliation_method NULL even though the single tx was reconciled
  via the same flow. Downstream readers (reconciliation reports,
  status indicators) would treat the two N=1 paths differently. New
  follow-up migration patches Branch B's final UPDATE.

RPC patch applied to remote via Supabase MCP. 26 unit tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 14:55:24 +02:00

453 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import { useEffect, useMemo, useState } from 'react'
import { useTranslations } from 'next-intl'
import { createClient } from '@/lib/supabase/client'
import { useCompany } from '@/contexts/CompanyContext'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { useToast } from '@/components/ui/use-toast'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { applyTemplate } from '@/lib/bookkeeping/template-library'
import { formatCurrency, formatDate, cn } from '@/lib/utils'
import { Loader2, FileText, AlertTriangle, Check } from 'lucide-react'
import type { BookingTemplateLibrary, BookingTemplateLibraryLine } from '@/types'
import type { TransactionWithInvoice } from './transaction-types'
interface BulkBookDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
transactions: TransactionWithInvoice[]
onSuccess: () => void
}
type Mode = 'one_line_per_tx' | 'sum_per_account'
interface PreviewLine {
account_number: string
debit_amount: number
credit_amount: number
line_description: string | undefined
}
function round2(n: number): number {
return Math.round(n * 100) / 100
}
export default function BulkBookDialog({
open,
onOpenChange,
transactions,
onSuccess,
}: BulkBookDialogProps) {
const { toast } = useToast()
const { company } = useCompany()
const supabase = useMemo(() => createClient(), [])
const t = useTranslations('tx_bulk_book')
const [templates, setTemplates] = useState<BookingTemplateLibrary[]>([])
const [loadingTemplates, setLoadingTemplates] = useState(true)
const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(null)
const [mode, setMode] = useState<Mode>('one_line_per_tx')
const [description, setDescription] = useState('')
const [submitting, setSubmitting] = useState(false)
const txCount = transactions.length
const sharedDate = transactions[0]?.date
const sharedCurrency = transactions[0]?.currency ?? 'SEK'
const direction: 'income' | 'expense' = useMemo(() => {
if (transactions.length === 0) return 'income'
return transactions[0]!.amount > 0 ? 'income' : 'expense'
}, [transactions])
const txSumAbs = useMemo(
() => round2(transactions.reduce((s, tx) => s + Math.abs(tx.amount), 0)),
[transactions],
)
const selectedTemplate = useMemo(
() => templates.find((tpl) => tpl.id === selectedTemplateId) ?? null,
[templates, selectedTemplateId],
)
// Load templates when the dialog opens. RLS scopes to user's companies +
// system templates; no company_id filter needed.
useEffect(() => {
if (!open || !company) return
let cancelled = false
async function load() {
setLoadingTemplates(true)
try {
const { data } = await supabase
.from('booking_template_library')
.select('*')
.eq('is_active', true)
.order('is_system', { ascending: false })
.order('name', { ascending: true })
if (cancelled) return
setTemplates((data ?? []) as BookingTemplateLibrary[])
} finally {
if (!cancelled) setLoadingTemplates(false)
}
}
load()
return () => {
cancelled = true
}
}, [open, company, supabase])
// Reset state when dialog closes so the next open starts clean.
useEffect(() => {
if (!open) {
setSelectedTemplateId(null)
setMode('one_line_per_tx')
setDescription('')
} else if (sharedDate) {
// Pre-fill description with a sensible default the user can edit.
setDescription(t('default_description', { date: sharedDate }))
}
}, [open, sharedDate, t])
// Live line preview — recomputes when template/mode/tx-set changes.
const previewLines = useMemo<PreviewLine[]>(() => {
if (!selectedTemplate) return []
const templateLines = (selectedTemplate.lines ?? []) as BookingTemplateLibraryLine[]
const lines: PreviewLine[] = []
if (mode === 'sum_per_account') {
const applied = applyTemplate(templateLines, txSumAbs)
for (const fl of applied) {
const debit = parseFloat(fl.debit_amount || '0') || 0
const credit = parseFloat(fl.credit_amount || '0') || 0
if (debit === 0 && credit === 0) continue
lines.push({
account_number: fl.account_number,
debit_amount: round2(debit),
credit_amount: round2(credit),
line_description: fl.line_description,
})
}
} else {
for (const tx of transactions) {
const applied = applyTemplate(templateLines, Math.abs(tx.amount))
for (const fl of applied) {
const debit = parseFloat(fl.debit_amount || '0') || 0
const credit = parseFloat(fl.credit_amount || '0') || 0
if (debit === 0 && credit === 0) continue
const tag = (tx.description || '').slice(0, 40).trim()
lines.push({
account_number: fl.account_number,
debit_amount: round2(debit),
credit_amount: round2(credit),
line_description: tag
? `${fl.line_description ?? ''} – ${tag}`.trim()
: fl.line_description,
})
}
}
}
return lines
}, [selectedTemplate, mode, transactions, txSumAbs])
const previewTotals = useMemo(() => {
const debit = previewLines.reduce((s, l) => s + l.debit_amount, 0)
const credit = previewLines.reduce((s, l) => s + l.credit_amount, 0)
return { debit: round2(debit), credit: round2(credit) }
}, [previewLines])
// Balance + bank-leg match are the two invariants the RPC will check; we
// surface them here so the user knows whether confirm will succeed.
const isBalanced = Math.abs(previewTotals.debit - previewTotals.credit) < 0.005
const bankLineNet = previewLines
.filter((l) => l.account_number >= '1900' && l.account_number <= '1999')
.reduce((s, l) => s + l.debit_amount - l.credit_amount, 0)
const expectedBankNet = direction === 'income' ? txSumAbs : -txSumAbs
const bankMatches = Math.abs(bankLineNet - expectedBankNet) < 0.005
const canConfirm =
!submitting &&
selectedTemplate !== null &&
description.trim().length > 0 &&
previewLines.length >= 2 &&
isBalanced &&
bankMatches
async function handleConfirm() {
if (!canConfirm) return
setSubmitting(true)
try {
const response = await fetch('/api/transactions/bulk-book', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tx_ids: transactions.map((tx) => tx.id),
template_id: selectedTemplateId,
mode,
entry_description: description.trim(),
}),
})
if (!response.ok) {
const body = await response.json().catch(() => null)
toast({
title: t('error_title'),
description: getErrorMessage(body, { statusCode: response.status }),
variant: 'destructive',
})
return
}
const body = (await response.json()) as {
data: { voucher_series: string | null; voucher_number: number | null }
}
const voucherLabel =
body.data.voucher_series && body.data.voucher_number != null
? `${body.data.voucher_series}-${body.data.voucher_number}`
: t('unknown_voucher')
toast({
title: t('success_title'),
description: t('success_description', { count: txCount, voucher: voucherLabel }),
variant: 'success',
})
onSuccess()
onOpenChange(false)
} catch (err) {
toast({
title: t('error_title'),
description: getErrorMessage(err),
variant: 'destructive',
})
} finally {
setSubmitting(false)
}
}
if (transactions.length === 0) return null
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[680px] max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{t('title', { count: txCount, date: sharedDate ? formatDate(sharedDate) : '' })}
</DialogTitle>
<DialogDescription>
{direction === 'income' ? t('description_income') : t('description_expense')}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* Selection summary */}
<div className="rounded-lg border bg-card p-3 flex items-center justify-between">
<div className="text-sm">
<p className="font-medium">
{t('summary_count', { count: txCount })}
</p>
<p className="text-xs text-muted-foreground tabular-nums">
{sharedDate ? formatDate(sharedDate) : ''}
</p>
</div>
<p className="font-medium tabular-nums">
{direction === 'income' ? '+' : '−'}
{formatCurrency(txSumAbs, sharedCurrency)}
</p>
</div>
{/* Template picker */}
<div className="space-y-2">
<Label>{t('template_label')}</Label>
{loadingTemplates ? (
<div className="space-y-2">
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
</div>
) : templates.length === 0 ? (
<div className="rounded-lg border border-dashed bg-muted/30 p-4 text-center text-sm text-muted-foreground">
{t('no_templates')}
</div>
) : (
<ul className="space-y-1 max-h-[180px] overflow-y-auto">
{templates.map((tpl) => (
<li key={tpl.id}>
<button
type="button"
onClick={() => setSelectedTemplateId(tpl.id)}
className={cn(
'w-full rounded-lg border bg-card p-3 text-left transition-colors hover:bg-secondary/60',
selectedTemplateId === tpl.id
? 'border-foreground'
: 'border-border',
)}
>
<div className="flex items-center justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<FileText className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
<span className="text-sm font-medium truncate">{tpl.name}</span>
{tpl.is_system && (
<Badge variant="outline" className="text-[10px]">
{t('system_badge')}
</Badge>
)}
</div>
{tpl.description && (
<p className="mt-1 text-xs text-muted-foreground truncate">
{tpl.description}
</p>
)}
</div>
</div>
</button>
</li>
))}
</ul>
)}
</div>
{/* Mode toggle — segmented control pattern (no RadioGroup primitive
in the design system; two outlined buttons act as a selectable
pair) */}
{selectedTemplate && (
<div className="space-y-2">
<Label>{t('mode_label')}</Label>
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => setMode('one_line_per_tx')}
className={cn(
'rounded-lg border bg-card p-3 text-left transition-colors hover:bg-secondary/60',
mode === 'one_line_per_tx' ? 'border-foreground' : 'border-border',
)}
>
<p className="text-sm font-medium">{t('mode_per_tx')}</p>
<p className="mt-1 text-xs text-muted-foreground">{t('mode_per_tx_hint')}</p>
</button>
<button
type="button"
onClick={() => setMode('sum_per_account')}
className={cn(
'rounded-lg border bg-card p-3 text-left transition-colors hover:bg-secondary/60',
mode === 'sum_per_account' ? 'border-foreground' : 'border-border',
)}
>
<p className="text-sm font-medium">{t('mode_sum')}</p>
<p className="mt-1 text-xs text-muted-foreground">{t('mode_sum_hint')}</p>
</button>
</div>
</div>
)}
{/* Description */}
{selectedTemplate && (
<div className="space-y-2">
<Label htmlFor="bulk-description">{t('description_label')}</Label>
<Input
id="bulk-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
maxLength={500}
/>
</div>
)}
{/* Live preview */}
{selectedTemplate && previewLines.length > 0 && (
<div className="space-y-2">
<Label>
{t('preview_label', { count: previewLines.length })}
</Label>
<div className="rounded-lg border bg-muted/30 overflow-hidden">
<table className="w-full text-xs tabular-nums">
<thead>
<tr className="border-b text-muted-foreground">
<th className="px-3 py-2 text-left font-medium">{t('col_account')}</th>
<th className="px-3 py-2 text-left font-medium">{t('col_description')}</th>
<th className="px-3 py-2 text-right font-medium">{t('col_debit')}</th>
<th className="px-3 py-2 text-right font-medium">{t('col_credit')}</th>
</tr>
</thead>
<tbody>
{previewLines.slice(0, 30).map((line, i) => (
<tr key={i} className="border-b border-border/40 last:border-b-0">
<td className="px-3 py-1.5 font-mono">{line.account_number}</td>
<td className="px-3 py-1.5 text-muted-foreground truncate max-w-[240px]">
{line.line_description ?? '—'}
</td>
<td className="px-3 py-1.5 text-right">
{line.debit_amount > 0 ? formatCurrency(line.debit_amount) : ''}
</td>
<td className="px-3 py-1.5 text-right">
{line.credit_amount > 0 ? formatCurrency(line.credit_amount) : ''}
</td>
</tr>
))}
{previewLines.length > 30 && (
<tr>
<td colSpan={4} className="px-3 py-1.5 text-center text-muted-foreground">
{t('preview_truncated', { remaining: previewLines.length - 30 })}
</td>
</tr>
)}
</tbody>
<tfoot>
<tr className="border-t bg-card font-medium">
<td colSpan={2} className="px-3 py-2 text-right">{t('total_label')}</td>
<td className="px-3 py-2 text-right">{formatCurrency(previewTotals.debit)}</td>
<td className="px-3 py-2 text-right">{formatCurrency(previewTotals.credit)}</td>
</tr>
</tfoot>
</table>
</div>
{/* Invariant indicators */}
<div className="space-y-1">
{isBalanced ? (
<div className="flex items-center gap-2 text-xs text-success">
<Check className="h-3.5 w-3.5" />
<span>{t('balance_ok')}</span>
</div>
) : (
<div className="flex items-center gap-2 text-xs text-destructive">
<AlertTriangle className="h-3.5 w-3.5" />
<span>{t('balance_off', {
delta: formatCurrency(Math.abs(previewTotals.debit - previewTotals.credit)),
})}</span>
</div>
)}
{bankMatches ? (
<div className="flex items-center gap-2 text-xs text-success">
<Check className="h-3.5 w-3.5" />
<span>{t('bank_ok')}</span>
</div>
) : (
<div className="flex items-center gap-2 text-xs text-destructive">
<AlertTriangle className="h-3.5 w-3.5" />
<span>{t('bank_off')}</span>
</div>
)}
</div>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={submitting}>
{t('cancel')}
</Button>
<Button onClick={handleConfirm} disabled={!canConfirm}>
{submitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('confirm')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}