diff --git a/app/(dashboard)/transactions/page.tsx b/app/(dashboard)/transactions/page.tsx index b8685e0f..b0d06499 100644 --- a/app/(dashboard)/transactions/page.tsx +++ b/app/(dashboard)/transactions/page.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState, useEffect, useRef } from 'react' +import { useState, useEffect, useMemo, useRef } from 'react' import { AnimatePresence } from 'framer-motion' import { useSearchParams } from 'next/navigation' import { useTranslations } from 'next-intl' @@ -20,7 +20,7 @@ import { DropdownMenuRadioGroup, DropdownMenuRadioItem, } from '@/components/ui/dropdown-menu' -import { ChevronDown, Search, Trash2, X } from 'lucide-react' +import { ChevronDown, Layers, Search, Trash2, X } from 'lucide-react' import TransactionForm from '@/components/transactions/TransactionForm' import BatchCategorySelector from '@/components/transactions/BatchCategorySelector' import TransactionStatusBar from '@/components/transactions/TransactionStatusBar' @@ -34,6 +34,7 @@ import InvoiceMatchDialog from '@/components/transactions/InvoiceMatchDialog' import InvoicePicker from '@/components/transactions/InvoicePicker' import SupplierInvoicePicker from '@/components/transactions/SupplierInvoicePicker' import MatchAllocationDialog from '@/components/transactions/MatchAllocationDialog' +import BulkBookDialog from '@/components/transactions/BulkBookDialog' import TransactionBookingDialog from '@/components/transactions/TransactionBookingDialog' import QuickReviewDialog from '@/components/transactions/QuickReviewDialog' @@ -123,6 +124,7 @@ export default function TransactionsPage() { const [supplierInvoicePickerTransaction, setSupplierInvoicePickerTransaction] = useState(null) const [splitMatchOpen, setSplitMatchOpen] = useState(false) const [splitMatchTransaction, setSplitMatchTransaction] = useState(null) + const [bulkBookOpen, setBulkBookOpen] = useState(false) const [isMatchingSupplierFromPicker, setIsMatchingSupplierFromPicker] = useState(false) const [isMatchingFromPicker, setIsMatchingFromPicker] = useState(false) @@ -1190,6 +1192,44 @@ export default function TransactionsPage() { setSplitMatchOpen(true) } + // Selected-tx derivation for bulk-book eligibility. + // The action bar shows "Bokför i klump" only when ≥2 txs are selected, + // share the same date, and same direction (all income or all expense) — + // matches the RPC's same-day + same-direction invariants so the user + // doesn't submit a guaranteed-fail batch. + const selectedTransactions = useMemo( + () => transactions.filter((t) => selectedIds.has(t.id)), + [transactions, selectedIds], + ) + const bulkBookEligible = useMemo(() => { + if (selectedTransactions.length < 2) return false + const first = selectedTransactions[0]! + return selectedTransactions.every( + (t) => t.date === first.date && (t.amount > 0) === (first.amount > 0), + ) + }, [selectedTransactions]) + + async function handleBulkBookSuccess() { + // Animate every selected tx out of the inbox, then refetch and clear + // the selection state. Mirrors the per-tx match success animation. + const ids = Array.from(selectedIds) + setExitingIds((prev) => { + const next = new Set(prev) + for (const id of ids) next.add(id) + return next + }) + await fetchTransactions() + setSelectedIds(new Set()) + setIsBatchMode(false) + setTimeout(() => { + setExitingIds((prev) => { + const next = new Set(prev) + for (const id of ids) next.delete(id) + return next + }) + }, 350) + } + async function handleSplitMatchSuccess() { if (!splitMatchTransaction) return const txId = splitMatchTransaction.id @@ -1799,6 +1839,23 @@ export default function TransactionsPage() { Ta bort + {/* Bulk-book (samlingsverifikation) — only when ≥2 selected on + the same date + same direction. Disabled state explains why + via title. */} + @@ -1835,6 +1892,13 @@ export default function TransactionsPage() { onSuccess={handleSplitMatchSuccess} /> + + { diff --git a/app/api/transactions/bulk-book/__tests__/route.test.ts b/app/api/transactions/bulk-book/__tests__/route.test.ts new file mode 100644 index 00000000..1f1a1e3f --- /dev/null +++ b/app/api/transactions/bulk-book/__tests__/route.test.ts @@ -0,0 +1,193 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { + createMockRequest, + parseJsonResponse, + createQueuedMockSupabase, +} from '@/tests/helpers' + +const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase() +vi.mock('@/lib/supabase/server', () => ({ + createClient: () => Promise.resolve(mockSupabase), +})) + +vi.mock('@/lib/events/bus', () => ({ + eventBus: { emit: vi.fn().mockResolvedValue(undefined) }, +})) + +vi.mock('@/lib/init', () => ({ + ensureInitialized: vi.fn(), +})) + +vi.mock('@/lib/company/context', () => ({ + requireCompanyId: vi.fn().mockResolvedValue('company-1'), + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: vi.fn().mockResolvedValue({ ok: true }), +})) + +// applyTemplate is the pure ratio/VAT expander used by the route. The +// route test stubs it so we don't need a real template object. +vi.mock('@/lib/bookkeeping/template-library', () => ({ + applyTemplate: vi.fn(), +})) + +import { POST } from '../route' +import { applyTemplate } from '@/lib/bookkeeping/template-library' + +const TX1 = '11111111-1111-4111-8111-111111111111' +const TX2 = '22222222-2222-4222-8222-222222222222' +const TPL = '33333333-3333-4333-8333-333333333333' +const JE = '44444444-4444-4444-8444-444444444444' + +describe('POST /api/transactions/bulk-book', () => { + const mockUser = { id: 'user-1', email: 'test@test.se' } + + beforeEach(() => { + vi.clearAllMocks() + reset() + mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } }) + }) + + it('returns 400 when neither template_id nor existing_journal_entry_id is set', async () => { + const request = createMockRequest('/api/transactions/bulk-book', { + method: 'POST', + body: { tx_ids: [TX1] }, + }) + const response = await POST(request) + expect(response.status).toBe(400) + }) + + it('returns 400 when both template_id and existing_journal_entry_id are set', async () => { + const request = createMockRequest('/api/transactions/bulk-book', { + method: 'POST', + body: { + tx_ids: [TX1], + template_id: TPL, + existing_journal_entry_id: JE, + mode: 'one_line_per_tx', + entry_description: 'Test', + }, + }) + const response = await POST(request) + expect(response.status).toBe(400) + }) + + it('link path passes through to RPC and returns the success envelope', async () => { + // RPC returns the link-existing happy path. + enqueue({ + data: { + ok: true, + mode: 'link_existing', + journal_entry_id: JE, + voucher_series: 'A', + voucher_number: 12, + linked_tx_count: 2, + tx_sum: 300, + }, + error: null, + }) + // Event re-fetch (empty is fine for the test). + enqueue({ data: [], error: null }) + + const request = createMockRequest('/api/transactions/bulk-book', { + method: 'POST', + body: { + tx_ids: [TX1, TX2], + existing_journal_entry_id: JE, + }, + }) + const response = await POST(request) + const { status, body } = await parseJsonResponse<{ + data: { mode: string; journal_entry_id: string; linked_tx_count: number } + }>(response) + expect(status).toBe(200) + expect(body.data.mode).toBe('link_existing') + expect(body.data.journal_entry_id).toBe(JE) + expect(body.data.linked_tx_count).toBe(2) + }) + + it('create-new path fetches template, expands per mode, and calls RPC', async () => { + // Template fetch. + enqueue({ + data: { + id: TPL, + name: 'Försäljning 25%', + lines: [ + { account: '1930', label: 'Bank', side: 'debit', type: 'settlement' }, + { account: '3001', label: 'Försäljning', side: 'credit', type: 'business', ratio: 0.8 }, + { account: '2611', label: 'Utg moms 25%', side: 'credit', type: 'vat', vat_rate: 0.25 }, + ], + is_active: true, + }, + error: null, + }) + // Tx fetch: 2 incomes totalling 300. + enqueue({ + data: [ + { id: TX1, amount: 100, currency: 'SEK', description: 'Swish 1', date: '2026-06-05' }, + { id: TX2, amount: 200, currency: 'SEK', description: 'Swish 2', date: '2026-06-05' }, + ], + error: null, + }) + + // applyTemplate stub — return a balanced 3-line set per call. + vi.mocked(applyTemplate).mockImplementation((_lines, total) => [ + { account_number: '1930', debit_amount: String(total), credit_amount: '', line_description: 'Bank' }, + { account_number: '3001', debit_amount: '', credit_amount: String(total * 0.8), line_description: 'Försäljning' }, + { account_number: '2611', debit_amount: '', credit_amount: String(total * 0.2), line_description: 'Utg moms 25%' }, + ]) + + // RPC returns happy path. + enqueue({ + data: { + ok: true, + mode: 'create_new', + journal_entry_id: JE, + voucher_series: 'A', + voucher_number: 13, + linked_tx_count: 2, + tx_sum: 300, + }, + error: null, + }) + // Event re-fetch. + enqueue({ data: [], error: null }) + + const request = createMockRequest('/api/transactions/bulk-book', { + method: 'POST', + body: { + tx_ids: [TX1, TX2], + template_id: TPL, + mode: 'one_line_per_tx', + entry_description: 'Samlingsverifikation 2026-06-05', + }, + }) + const response = await POST(request) + const { status, body } = await parseJsonResponse<{ + data: { mode: string; journal_entry_id: string } + }>(response) + expect(status).toBe(200) + expect(body.data.mode).toBe('create_new') + expect(body.data.journal_entry_id).toBe(JE) + // Template expansion was invoked once per tx in one_line_per_tx mode. + expect(vi.mocked(applyTemplate)).toHaveBeenCalledTimes(2) + }) + + it('maps RPC structured failure code to errorResponseFromCode', async () => { + enqueue({ + data: { ok: false, code: 'BULK_BOOK_DATE_MISMATCH', details: { expected: '2026-06-05', got: '2026-06-06' } }, + error: null, + }) + + const request = createMockRequest('/api/transactions/bulk-book', { + method: 'POST', + body: { tx_ids: [TX1, TX2], existing_journal_entry_id: JE }, + }) + const response = await POST(request) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response) + expect(status).toBe(400) + expect(body.error.code).toBe('BULK_BOOK_DATE_MISMATCH') + }) +}) diff --git a/app/api/transactions/bulk-book/route.ts b/app/api/transactions/bulk-book/route.ts new file mode 100644 index 00000000..020752d0 --- /dev/null +++ b/app/api/transactions/bulk-book/route.ts @@ -0,0 +1,254 @@ +import { NextResponse } from 'next/server' +import { withRouteContext } from '@/lib/api/with-route-context' +import { validateBody } from '@/lib/api/validate' +import { BulkBookSchema } from '@/lib/api/schemas' +import { errorResponseFromCode } from '@/lib/errors/get-structured-error' +import { applyTemplate } from '@/lib/bookkeeping/template-library' +import { eventBus } from '@/lib/events/bus' +import { ensureInitialized } from '@/lib/init' +import type { BookingTemplateLibraryLine, Transaction } from '@/types' + +ensureInitialized() + +interface RpcOk { + ok: true + mode: 'link_existing' | 'create_new' + journal_entry_id: string + voucher_series: string | null + voucher_number: number | null + linked_tx_count: number + tx_sum: number +} + +interface RpcErr { + ok: false + code: string + details?: Record +} + +interface ComputedLine { + account_number: string + debit_amount: number + credit_amount: number + currency: string + line_description?: string + sort_order?: number +} + +function round2(n: number): number { + return Math.round(n * 100) / 100 +} + +/** + * POST /api/transactions/bulk-book + * + * Bulk-book N bank transactions on the same date into one combined + * verifikat (samlingsverifikation per BFL 5 kap 6§). Two flows: + * + * 1. Link to existing voucher — { tx_ids, existing_journal_entry_id }. + * No new JE; the RPC just inserts N transaction_voucher_links rows. + * + * 2. Create new from template — { tx_ids, template_id, mode, + * entry_description }. The route fetches the template, expands it + * per the chosen mode, and passes the resulting balanced lines to + * the RPC. The RPC then commits the verifikat atomically. + * + * `applyTemplate` lives in TS (ratio / VAT math); the RPC stays focused + * on locking, balance, and link insertion. + */ +export const POST = withRouteContext( + 'transaction.bulk_book', + async (request, ctx) => { + const { user, supabase, companyId, log, requestId } = ctx + + const validation = await validateBody(request, BulkBookSchema, { + log, + operation: 'transaction.bulk_book', + }) + if (!validation.success) return validation.response + const body = validation.data + + const opLog = log.child({ txCount: body.tx_ids.length }) + + // Branch 2 needs the template + tx amounts; branch 1 hands off to the + // RPC directly with a null new_entry. + let newEntryPayload: { description: string; lines: ComputedLine[] } | null = null + + if (body.template_id && body.mode && body.entry_description) { + // Fetch the template. RLS scopes to user's companies + system templates, + // so we don't need a company_id filter here. + const { data: template, error: templateError } = await supabase + .from('booking_template_library') + .select('id, name, lines, is_active') + .eq('id', body.template_id) + .single() + + if (templateError || !template) { + return errorResponseFromCode('BULK_BOOK_TEMPLATE_NOT_FOUND', opLog, { requestId }) + } + if (!template.is_active) { + return errorResponseFromCode('BULK_BOOK_TEMPLATE_NOT_FOUND', opLog, { + requestId, + details: { reason: 'template_inactive' }, + }) + } + + const templateLines = (template.lines ?? []) as BookingTemplateLibraryLine[] + + // Need each tx's amount + currency to expand per mode. The RPC also + // re-validates (date, direction, not-already-booked) but we need the + // amount sum to drive the template expansion. + const { data: txs, error: txError } = await supabase + .from('transactions') + .select('id, amount, currency, description, date') + .in('id', body.tx_ids) + .eq('company_id', companyId) + + if (txError || !txs || txs.length === 0) { + return errorResponseFromCode('BULK_BOOK_TXS_NOT_FOUND', opLog, { requestId }) + } + if (txs.length !== body.tx_ids.length) { + return errorResponseFromCode('BULK_BOOK_TXS_NOT_FOUND', opLog, { + requestId, + details: { expected: body.tx_ids.length, found: txs.length }, + }) + } + + const txTyped = txs as Pick[] + + // Same-currency invariant for v1. Mixed-currency batches would need + // FX conversion per tx; out of scope. Use the dedicated + // BULK_BOOK_MIXED_CURRENCY code so the toast doesn't blame direction + // (PR #606 review fix). + const currencies = new Set(txTyped.map((t) => t.currency)) + if (currencies.size > 1) { + return errorResponseFromCode('BULK_BOOK_MIXED_CURRENCY', opLog, { + requestId, + details: { currencies: Array.from(currencies) }, + }) + } + const currency = txTyped[0]!.currency + + const txAbsAmounts = txTyped.map((t) => Math.abs(t.amount)) + const totalAbs = round2(txAbsAmounts.reduce((s, a) => s + a, 0)) + + const lines: ComputedLine[] = [] + let sortOrder = 0 + + if (body.mode === 'sum_per_account') { + // One application of the template at the summed amount → one line + // per template line. Compact verifikat; per-tx detail recoverable + // via transaction_voucher_links. + const applied = applyTemplate(templateLines, totalAbs) + for (const formLine of applied) { + const debit = parseFloat(formLine.debit_amount || '0') || 0 + const credit = parseFloat(formLine.credit_amount || '0') || 0 + if (debit === 0 && credit === 0) continue + lines.push({ + account_number: formLine.account_number, + debit_amount: round2(debit), + credit_amount: round2(credit), + currency, + line_description: formLine.line_description || undefined, + sort_order: sortOrder++, + }) + } + } else { + // one_line_per_tx — apply template per tx, prefix description with + // a short tx reference so the verifikat preserves per-row audit + // detail (BFL 5 kap 7§ motpart identification). + for (const tx of txTyped) { + const applied = applyTemplate(templateLines, Math.abs(tx.amount)) + for (const formLine of applied) { + const debit = parseFloat(formLine.debit_amount || '0') || 0 + const credit = parseFloat(formLine.credit_amount || '0') || 0 + if (debit === 0 && credit === 0) continue + const txTag = (tx.description || '').slice(0, 40).trim() + lines.push({ + account_number: formLine.account_number, + debit_amount: round2(debit), + credit_amount: round2(credit), + currency, + line_description: txTag + ? `${formLine.line_description ?? ''} – ${txTag}`.trim() + : formLine.line_description || undefined, + sort_order: sortOrder++, + }) + } + } + } + + newEntryPayload = { + description: body.entry_description, + lines, + } + } + + const { data, error } = await supabase.rpc('bulk_book_transactions', { + p_tx_ids: body.tx_ids, + p_existing_journal_entry_id: body.existing_journal_entry_id ?? null, + p_new_entry: newEntryPayload, + p_user_id: user.id, + p_company_id: companyId, + }) + + if (error) { + opLog.error('bulk_book_transactions RPC error', error) + return errorResponseFromCode('BULK_BOOK_RPC_FAILED', opLog, { + requestId, + details: { message: error.message }, + }) + } + + const result = data as RpcOk | RpcErr | null + if (!result || !result.ok) { + const code = (result as RpcErr | null)?.code ?? 'BULK_BOOK_RPC_FAILED' + const details = (result as RpcErr | null)?.details + return errorResponseFromCode(code, opLog, { requestId, details }) + } + + // Emit one transaction.reconciled event per tx so existing subscribers + // (reminder cancellation, automation, processing-history) keep working. + // Best-effort; a failure here does not roll back the booking. + const { data: linkedTxs } = await supabase + .from('transactions') + .select('*') + .in('id', body.tx_ids) + .eq('company_id', companyId) + + if (linkedTxs) { + for (const tx of linkedTxs as Transaction[]) { + try { + await eventBus.emit({ + type: 'transaction.reconciled', + payload: { + transaction: tx, + journalEntryId: result.journal_entry_id, + method: 'manual', + userId: user.id, + companyId, + }, + }) + } catch (err) { + opLog.warn('bulk_book transaction.reconciled emission failed', { + err, + txId: tx.id, + journalEntryId: result.journal_entry_id, + }) + } + } + } + + return NextResponse.json({ + data: { + mode: result.mode, + journal_entry_id: result.journal_entry_id, + voucher_series: result.voucher_series, + voucher_number: result.voucher_number, + linked_tx_count: result.linked_tx_count, + tx_sum: result.tx_sum, + }, + }) + }, + { requireWrite: true }, +) diff --git a/components/transactions/BulkBookDialog.tsx b/components/transactions/BulkBookDialog.tsx new file mode 100644 index 00000000..8abe2b8b --- /dev/null +++ b/components/transactions/BulkBookDialog.tsx @@ -0,0 +1,452 @@ +'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([]) + const [loadingTemplates, setLoadingTemplates] = useState(true) + const [selectedTemplateId, setSelectedTemplateId] = useState(null) + const [mode, setMode] = useState('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(() => { + 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 ( + + + + + {t('title', { count: txCount, date: sharedDate ? formatDate(sharedDate) : '' })} + + + {direction === 'income' ? t('description_income') : t('description_expense')} + + + +
+ {/* Selection summary */} +
+
+

+ {t('summary_count', { count: txCount })} +

+

+ {sharedDate ? formatDate(sharedDate) : ''} +

+
+

+ {direction === 'income' ? '+' : '−'} + {formatCurrency(txSumAbs, sharedCurrency)} +

+
+ + {/* Template picker */} +
+ + {loadingTemplates ? ( +
+ + +
+ ) : templates.length === 0 ? ( +
+ {t('no_templates')} +
+ ) : ( +
    + {templates.map((tpl) => ( +
  • + +
  • + ))} +
+ )} +
+ + {/* Mode toggle — segmented control pattern (no RadioGroup primitive + in the design system; two outlined buttons act as a selectable + pair) */} + {selectedTemplate && ( +
+ +
+ + +
+
+ )} + + {/* Description */} + {selectedTemplate && ( +
+ + setDescription(e.target.value)} + maxLength={500} + /> +
+ )} + + {/* Live preview */} + {selectedTemplate && previewLines.length > 0 && ( +
+ +
+ + + + + + + + + + + {previewLines.slice(0, 30).map((line, i) => ( + + + + + + + ))} + {previewLines.length > 30 && ( + + + + )} + + + + + + + + +
{t('col_account')}{t('col_description')}{t('col_debit')}{t('col_credit')}
{line.account_number} + {line.line_description ?? '—'} + + {line.debit_amount > 0 ? formatCurrency(line.debit_amount) : ''} + + {line.credit_amount > 0 ? formatCurrency(line.credit_amount) : ''} +
+ {t('preview_truncated', { remaining: previewLines.length - 30 })} +
{t('total_label')}{formatCurrency(previewTotals.debit)}{formatCurrency(previewTotals.credit)}
+
+ + {/* Invariant indicators */} +
+ {isBalanced ? ( +
+ + {t('balance_ok')} +
+ ) : ( +
+ + {t('balance_off', { + delta: formatCurrency(Math.abs(previewTotals.debit - previewTotals.credit)), + })} +
+ )} + {bankMatches ? ( +
+ + {t('bank_ok')} +
+ ) : ( +
+ + {t('bank_off')} +
+ )} +
+
+ )} +
+ + + + + +
+
+ ) +} diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index 6cce7047..4eb8fbc6 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -518,6 +518,62 @@ export const LinkSupplierInvoiceToVoucherSchema = z.object({ notes: z.string().max(2000).optional(), }) +/** + * Bulk-book N bank transactions on the same date into one combined verifikat + * (samlingsverifikation per BFL 5 kap 6§). Two flows multiplexed by which + * field is set: + * + * - `existing_journal_entry_id`: link the txs to an already-posted voucher + * (no new JE created). The voucher's 19xx net must equal the tx sum. + * + * - `template_id` + `mode` + `entry_description`: build a new verifikat + * by applying the booking template to each tx. The route does the ratio + * expansion (one_line_per_tx OR sum_per_account) and passes the final + * lines to the RPC. + * + * Exactly one of the two paths must be set — enforced by superRefine. + */ +export const BulkBookSchema = z + .object({ + tx_ids: z + .array(uuid) + .min(1, 'At least one transaction is required') + .max(200, 'At most 200 transactions per batch'), + existing_journal_entry_id: uuid.optional(), + template_id: uuid.optional(), + mode: z.enum(['one_line_per_tx', 'sum_per_account']).optional(), + entry_description: z.string().min(1).max(500).optional(), + }) + .superRefine((data, ctx) => { + const hasExisting = !!data.existing_journal_entry_id + const hasTemplate = !!data.template_id + if (hasExisting === hasTemplate) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + 'Provide either existing_journal_entry_id (link) or template_id (create new) — not both, and not neither', + path: ['existing_journal_entry_id'], + }) + return + } + if (hasTemplate) { + if (!data.mode) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'mode is required when template_id is set', + path: ['mode'], + }) + } + if (!data.entry_description) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'entry_description is required when template_id is set', + path: ['entry_description'], + }) + } + } + }) + /** * Allocate one bank transaction across N customer OR N supplier invoices. * Backed by the match_batch_allocate PL/pgSQL RPC, which builds a single diff --git a/lib/errors/structured-errors.ts b/lib/errors/structured-errors.ts index aff0c93f..8288a70e 100644 --- a/lib/errors/structured-errors.ts +++ b/lib/errors/structured-errors.ts @@ -1930,6 +1930,141 @@ const MATCH_BATCH: Record = { }, } +// ───────────────────────────────────────────────────────────────── +// Bulk-book (bulk_book_transactions RPC): N txs → 1 verifikat +// ───────────────────────────────────────────────────────────────── + +const BULK_BOOK: Record = { + BULK_BOOK_UNAUTHORIZED: { + httpStatus: 403, + message_sv: 'Du har inte behörighet att bokföra transaktioner för det här företaget.', + message_en: 'You are not authorized to bulk-book transactions for this company.', + }, + BULK_BOOK_NO_TXS: { + httpStatus: 400, + message_sv: 'Inga transaktioner att bokföra.', + message_en: 'No transactions to book.', + }, + BULK_BOOK_TXS_NOT_FOUND: { + httpStatus: 404, + message_sv: 'En eller flera transaktioner kunde inte hittas i det aktuella företaget.', + message_en: 'One or more transactions could not be found in this company.', + }, + BULK_BOOK_TX_ALREADY_BOOKED: { + httpStatus: 409, + message_sv: + 'En av de valda transaktionerna är redan bokförd. Avbokföra (storno) den först eller välj bort den.', + message_en: + 'One of the selected transactions is already booked. Reverse the existing journal entry first or deselect it.', + }, + BULK_BOOK_TX_ZERO_AMOUNT: { + httpStatus: 400, + message_sv: 'Transaktioner med beloppet 0 kan inte ingå i en samlingsbokföring.', + message_en: 'Zero-amount transactions cannot be part of a bulk booking.', + }, + BULK_BOOK_DATE_MISMATCH: { + httpStatus: 400, + message_sv: + 'Alla transaktioner i en samlingsbokföring måste ha samma datum (BFL 5 kap 6§).', + message_en: + 'All transactions in a bulk booking must share the same date (BFL 5 kap 6§).', + }, + BULK_BOOK_DIRECTION_MISMATCH: { + httpStatus: 400, + message_sv: + 'Alla transaktioner måste vara samma riktning (alla intäkter eller alla utgifter).', + message_en: 'All transactions must be the same direction (all income or all expense).', + }, + BULK_BOOK_MIXED_CURRENCY: { + httpStatus: 400, + message_sv: + 'Samlingsbokföring stödjer endast transaktioner i samma valuta. Välj transaktioner i en valuta åt gången.', + message_en: + 'Bulk booking supports only single-currency batches. Select transactions in one currency at a time.', + }, + BULK_BOOK_INVALID_PAYLOAD: { + httpStatus: 400, + message_sv: + 'Ange antingen existing_journal_entry_id (länkning) eller template_id (skapa ny) — inte båda, och inte ingen.', + message_en: + 'Provide either existing_journal_entry_id (link) or template_id (create new) — not both, and not neither.', + }, + BULK_BOOK_TEMPLATE_NOT_FOUND: { + httpStatus: 404, + message_sv: 'Den valda bokföringsmallen kunde inte hittas.', + message_en: 'The selected booking template could not be found.', + }, + BULK_BOOK_VOUCHER_NOT_FOUND: { + httpStatus: 404, + message_sv: 'Verifikationen kunde inte hittas.', + message_en: 'The target journal entry could not be found.', + }, + BULK_BOOK_VOUCHER_NOT_POSTED: { + httpStatus: 409, + message_sv: 'Endast bokförda verifikationer kan länkas mot banktransaktioner.', + message_en: 'Only posted journal entries can be linked.', + }, + BULK_BOOK_NO_BANK_LINE: { + httpStatus: 400, + message_sv: + 'Verifikationen har ingen rad på bankkonto (19xx). Den kan inte länkas mot banktransaktioner.', + message_en: + 'The journal entry has no bank-account (19xx) line and cannot be linked to bank transactions.', + }, + BULK_BOOK_AMOUNT_MISMATCH: { + httpStatus: 400, + message_sv: + 'Summan av transaktionerna stämmer inte med bankradens nettobelopp på verifikationen.', + message_en: + 'The sum of the selected transactions does not match the bank-line net amount on the journal entry.', + }, + BULK_BOOK_NO_LINES: { + httpStatus: 400, + message_sv: 'Verifikationen måste innehålla minst två rader (debit och kredit).', + message_en: 'The journal entry must contain at least two lines (debit and credit).', + }, + BULK_BOOK_UNBALANCED: { + httpStatus: 400, + message_sv: 'Verifikationen balanserar inte — summa debet måste lika summa kredit.', + message_en: 'The journal entry does not balance — debits must equal credits.', + }, + BULK_BOOK_NEGATIVE_LINE: { + httpStatus: 400, + message_sv: 'Verifikationsrader kan inte ha negativa belopp.', + message_en: 'Journal entry lines cannot have negative amounts.', + }, + BULK_BOOK_BOTH_SIDES_NONZERO: { + httpStatus: 400, + message_sv: 'En verifikationsrad kan inte ha både debet och kredit nollskilda.', + message_en: 'A journal entry line cannot have both debit and credit non-zero.', + }, + BULK_BOOK_MISSING_DESCRIPTION: { + httpStatus: 400, + message_sv: 'Beskrivning krävs för en ny samlingsverifikation.', + message_en: 'Description is required when creating a new combined journal entry.', + }, + BULK_BOOK_NO_FISCAL_PERIOD: { + httpStatus: 400, + message_sv: + 'Det finns ingen öppen räkenskapsperiod för transaktionsdatumet. Skapa perioden först.', + message_en: + 'No fiscal period exists for the transaction date. Create the period first.', + }, + BULK_BOOK_PERIOD_LOCKED: { + httpStatus: 409, + message_sv: + 'Räkenskapsperioden för transaktionsdatumet är stängd. Öppna perioden eller välj ett annat datum.', + message_en: + 'The fiscal period for the transaction date is closed/locked.', + }, + BULK_BOOK_RPC_FAILED: { + httpStatus: 500, + message_sv: 'Databasfel under samlingsbokföring. Försök igen.', + message_en: 'Database error during bulk booking. Please retry.', + retryable: true, + }, +} + // ───────────────────────────────────────────────────────────────── // Combined registry // ───────────────────────────────────────────────────────────────── @@ -1943,6 +2078,7 @@ const REGISTRY: Record = { ...LINK_INVOICE_VOUCHER, ...LINK_SI_VOUCHER, ...MATCH_BATCH, + ...BULK_BOOK, ...MATCH_SI, ...INVOICE, ...SUPPLIER_INVOICE, diff --git a/lib/transactions/__tests__/is-booked.test.ts b/lib/transactions/__tests__/is-booked.test.ts new file mode 100644 index 00000000..a5adbbae --- /dev/null +++ b/lib/transactions/__tests__/is-booked.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect } from 'vitest' +import { isTransactionBooked, getPrimaryJournalEntryId } from '../is-booked' + +describe('isTransactionBooked', () => { + it('returns false for a tx with no journal entry, payments, or voucher links', () => { + const tx = { id: 'tx-1', journal_entry_id: null } + expect(isTransactionBooked(tx)).toBe(false) + expect(isTransactionBooked(tx, [], [])).toBe(false) + }) + + it('returns true when transactions.journal_entry_id is set (1:1 case)', () => { + const tx = { id: 'tx-1', journal_entry_id: 'je-1' } + expect(isTransactionBooked(tx)).toBe(true) + }) + + it('returns true when a matching invoice_payments row exists (multi-allocation)', () => { + const tx = { id: 'tx-1', journal_entry_id: null } + const payments = [{ transaction_id: 'tx-1' }] + expect(isTransactionBooked(tx, payments)).toBe(true) + }) + + it('returns true when a matching supplier_invoice_payments row exists', () => { + const tx = { id: 'tx-1', journal_entry_id: null } + const payments = [{ transaction_id: 'tx-1' }] + expect(isTransactionBooked(tx, payments)).toBe(true) + }) + + it('returns true when a transaction_voucher_links row references the tx (bulk-book)', () => { + const tx = { id: 'tx-1', journal_entry_id: null } + const links = [{ transaction_id: 'tx-1' }] + expect(isTransactionBooked(tx, [], links)).toBe(true) + }) + + it('ignores payment / voucher-link rows that reference a different tx', () => { + const tx = { id: 'tx-1', journal_entry_id: null } + const payments = [{ transaction_id: 'tx-other' }] + const links = [{ transaction_id: 'tx-other' }] + expect(isTransactionBooked(tx, payments, links)).toBe(false) + }) +}) + +describe('getPrimaryJournalEntryId', () => { + it('returns null when nothing is anchored', () => { + const tx = { id: 'tx-1', journal_entry_id: null } + expect(getPrimaryJournalEntryId(tx)).toBeNull() + }) + + it('prefers transactions.journal_entry_id when set', () => { + const tx = { id: 'tx-1', journal_entry_id: 'je-1' } + const payments = [{ transaction_id: 'tx-1', journal_entry_id: 'je-payment' }] + const links = [{ transaction_id: 'tx-1', journal_entry_id: 'je-link' }] + expect(getPrimaryJournalEntryId(tx, payments, links)).toBe('je-1') + }) + + it('falls back to voucher-link when tx.journal_entry_id is null', () => { + const tx = { id: 'tx-1', journal_entry_id: null } + const links = [{ transaction_id: 'tx-1', journal_entry_id: 'je-link' }] + expect(getPrimaryJournalEntryId(tx, [], links)).toBe('je-link') + }) + + it('falls back to invoice_payments JE when no link exists', () => { + const tx = { id: 'tx-1', journal_entry_id: null } + const payments = [{ transaction_id: 'tx-1', journal_entry_id: 'je-payment' }] + expect(getPrimaryJournalEntryId(tx, payments, [])).toBe('je-payment') + }) + + it('returns null when matching payment has journal_entry_id=null', () => { + // Edge: an invoice_payments row that pre-dates the JE creation (the + // engine's non-blocking JE write can leave this null briefly). + const tx = { id: 'tx-1', journal_entry_id: null } + const payments = [{ transaction_id: 'tx-1', journal_entry_id: null }] + expect(getPrimaryJournalEntryId(tx, payments, [])).toBeNull() + }) +}) diff --git a/lib/transactions/is-booked.ts b/lib/transactions/is-booked.ts new file mode 100644 index 00000000..1eb2c8c9 --- /dev/null +++ b/lib/transactions/is-booked.ts @@ -0,0 +1,87 @@ +/** + * Centralised predicate for "is this bank transaction anchored to a + * verifikat?" — single source of truth that readers across the inbox, + * history list, and MCP filters use to decide whether a tx is unbooked + * (needs categorisation) vs already attached to a journal entry. + * + * Three storage locations to consider, all of which can independently + * make a tx "booked": + * + * 1. transactions.journal_entry_id — the 1:1 case (single tx → single + * verifikat via categorisation, match-invoice, or match-supplier-invoice). + * + * 2. invoice_payments / supplier_invoice_payments — the multi-allocation + * case (PR #603's match_batch_allocate). One tx with multiple payment + * rows pointing at the same combined verifikat; the row in transactions + * itself has journal_entry_id = NULL because no single invoice ID + * captures the full picture. + * + * 3. transaction_voucher_links — the N-tx-to-1-JE case (the bulk-book + * flow). Same combined verifikat, multiple bank lines, each tx's row + * in transactions has journal_entry_id = NULL for N>1. + * + * If a reader only checks `tx.journal_entry_id`, every multi-tx and + * multi-allocation case falsely shows as "unbooked" and would re-surface + * in the inbox or hide the "Open verifikat" affordance. Use this helper + * to avoid that. + * + * The Postgres mirror is `public.is_transaction_booked(uuid)` + * (migration 20260529120000_transaction_voucher_links.sql) — same + * predicate, three storage locations, in SQL. + */ + +interface TxLike { + id: string + journal_entry_id: string | null +} + +interface PaymentLike { + transaction_id: string | null +} + +interface VoucherLinkLike { + transaction_id: string +} + +/** + * @param tx - the bank transaction row (must include `journal_entry_id`) + * @param payments - rows from invoice_payments AND supplier_invoice_payments + * filtered to ones whose transaction_id might equal tx.id. + * May be empty if the reader didn't fetch them. + * @param voucherLinks - rows from transaction_voucher_links filtered to ones + * whose transaction_id might equal tx.id. May be empty. + */ +export function isTransactionBooked( + tx: TxLike, + payments: PaymentLike[] = [], + voucherLinks: VoucherLinkLike[] = [], +): boolean { + if (tx.journal_entry_id != null) return true + if (payments.some((p) => p.transaction_id === tx.id)) return true + if (voucherLinks.some((v) => v.transaction_id === tx.id)) return true + return false +} + +/** + * Resolve the "primary" journal_entry_id to link to from the UI when a + * tx has multiple anchoring rows. Order of precedence: + * + * 1. tx.journal_entry_id (the 1:1 case — always the right answer) + * 2. First voucher-link row (multi-tx bulk-book points all txs at one JE) + * 3. First payment row (multi-allocation puts each invoice on its own + * payment row but they all share the combined verifikat) + * + * Returns null if none of the three are present, in which case the tx + * is not booked at all. + */ +export function getPrimaryJournalEntryId( + tx: TxLike, + payments: { transaction_id: string | null; journal_entry_id: string | null }[] = [], + voucherLinks: { transaction_id: string; journal_entry_id: string }[] = [], +): string | null { + if (tx.journal_entry_id != null) return tx.journal_entry_id + const link = voucherLinks.find((v) => v.transaction_id === tx.id) + if (link) return link.journal_entry_id + const payment = payments.find((p) => p.transaction_id === tx.id && p.journal_entry_id != null) + return payment?.journal_entry_id ?? null +} diff --git a/messages/en.json b/messages/en.json index 1a9c6e45..628ae9c2 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1816,6 +1816,39 @@ "exact_match": "Exact match", "no_search_results": "No invoice matches \"{term}\"" }, + "tx_bulk_book": { + "title": "Book {count} transactions from {date}", + "description_income": "Create a single combined verifikat covering all selected incoming payments (BFL 5 kap 6§).", + "description_expense": "Create a single combined verifikat covering all selected outgoing payments (BFL 5 kap 6§).", + "summary_count": "{count, plural, one {# transaction} other {# transactions}}", + "template_label": "Booking template", + "no_templates": "No active templates available.", + "system_badge": "Standard", + "mode_label": "How should the lines be grouped?", + "mode_per_tx": "One line per transaction", + "mode_per_tx_hint": "Preserves per-transaction audit detail — more lines.", + "mode_sum": "Sum per account", + "mode_sum_hint": "Compact verifikat — all amounts summed per account.", + "description_label": "Voucher description", + "default_description": "Combined verifikat {date}", + "preview_label": "Preview ({count} lines)", + "preview_truncated": "+ {remaining} more lines on booking", + "col_account": "Account", + "col_description": "Description", + "col_debit": "Debit", + "col_credit": "Credit", + "total_label": "Total", + "balance_ok": "The verifikat balances.", + "balance_off": "The verifikat doesn't balance (delta {delta}).", + "bank_ok": "Bank line matches the transactions.", + "bank_off": "The bank line's net amount doesn't match the transactions.", + "error_title": "Bulk booking failed", + "success_title": "Combined verifikat created", + "success_description": "{count} transactions booked to verifikat {voucher}.", + "unknown_voucher": "(no number)", + "cancel": "Cancel", + "confirm": "Confirm booking" + }, "tx_match_allocation": { "title": "Split payment", "description_customer": "Allocate the incoming payment across one or more customer invoices. The verifikat lands as a samlingsverifikation per BFL 5 kap 6§.", diff --git a/messages/sv.json b/messages/sv.json index 0fd34fe3..c42e0ce1 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -1816,6 +1816,39 @@ "exact_match": "Exakt match", "no_search_results": "Ingen faktura matchar \"{term}\"" }, + "tx_bulk_book": { + "title": "Bokför {count} transaktioner från {date}", + "description_income": "Skapa en samlingsverifikation som täcker alla valda inbetalningar (BFL 5 kap 6§).", + "description_expense": "Skapa en samlingsverifikation som täcker alla valda utbetalningar (BFL 5 kap 6§).", + "summary_count": "{count, plural, one {# transaktion} other {# transaktioner}}", + "template_label": "Bokföringsmall", + "no_templates": "Inga aktiva mallar tillgängliga.", + "system_badge": "Standard", + "mode_label": "Hur ska raderna grupperas?", + "mode_per_tx": "En rad per transaktion", + "mode_per_tx_hint": "Behåller spårbarhet per transaktion — fler rader.", + "mode_sum": "Summera per konto", + "mode_sum_hint": "Kompakt verifikation — alla belopp summeras per konto.", + "description_label": "Beskrivning på verifikationen", + "default_description": "Samlingsverifikation {date}", + "preview_label": "Förhandsvisning ({count} rader)", + "preview_truncated": "+ {remaining} fler rader visas vid bokföring", + "col_account": "Konto", + "col_description": "Beskrivning", + "col_debit": "Debet", + "col_credit": "Kredit", + "total_label": "Totalt", + "balance_ok": "Verifikationen balanserar.", + "balance_off": "Verifikationen balanserar inte (differens {delta}).", + "bank_ok": "Bankraden matchar transaktionerna.", + "bank_off": "Bankradens nettobelopp matchar inte transaktionerna.", + "error_title": "Bokföringen misslyckades", + "success_title": "Samlingsverifikation skapad", + "success_description": "{count} transaktioner bokförda till verifikat {voucher}.", + "unknown_voucher": "(utan nummer)", + "cancel": "Avbryt", + "confirm": "Bekräfta bokföring" + }, "tx_match_allocation": { "title": "Dela betalning", "description_customer": "Fördela inbetalningen på en eller flera kundfakturor. Verifikationen skapas som en samlingsverifikation per BFL 5 kap 6§.", diff --git a/supabase/migrations/20260530120000_bulk_book_transactions.sql b/supabase/migrations/20260530120000_bulk_book_transactions.sql new file mode 100644 index 00000000..d29bb5a6 --- /dev/null +++ b/supabase/migrations/20260530120000_bulk_book_transactions.sql @@ -0,0 +1,366 @@ +-- Phase 3b — bulk_book_transactions RPC. +-- +-- The second of the two multi-tx ↔ multi-voucher flows. Where +-- match_batch_allocate takes 1 tx and spreads it across N invoices, this +-- RPC 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 case: 10 daily card/Swish receipts → one +-- voucher with either (a) one debit/credit pair per tx (one_line_per_tx +-- mode, full audit detail) or (b) one summed debit + one summed credit +-- per account (sum_per_account mode, compact verifikat). +-- +-- Two branches: +-- +-- 1. Link to existing posted verifikat (p_existing_journal_entry_id set): +-- No new JE. Inserts N transaction_voucher_links rows. Validates that +-- the JE's 19xx net equals sum(tx.amount). Use case: SIE-imported +-- day-summary voucher; user retroactively links the bank lines. +-- +-- 2. Create new combined verifikat (p_new_entry set): +-- The route's applyTemplate() has already done ratio/VAT expansion +-- per the chosen mode. The RPC validates the lines are balanced and +-- the 1930 net matches sum(tx.amount), then inserts the verifikat +-- atomically (commit_journal_entry assigns the voucher number). +-- +-- Same security pattern as match_batch_allocate: caller membership check, +-- SELECT … FOR UPDATE on each tx in id order (deadlock-stable). + +CREATE OR REPLACE FUNCTION public.bulk_book_transactions( + p_tx_ids uuid[], + p_existing_journal_entry_id uuid, + p_new_entry jsonb, + p_user_id uuid, + p_company_id uuid +) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path TO 'public' +AS $$ +DECLARE + v_tx RECORD; + v_tx_id uuid; + v_tx_date date; + v_total_amount numeric := 0; + v_total_amount_abs numeric; + v_direction text; -- 'income' (positive) or 'expense' (negative) + v_tx_count int := 0; + + v_voucher RECORD; + v_voucher_bank_net numeric := 0; + + v_fiscal_period_id uuid; + v_period_is_closed boolean; + v_period_locked_at timestamptz; + + v_journal_entry_id uuid; + v_voucher_series text := 'A'; + v_voucher_number int; + v_entry_description text; + + v_line jsonb; + v_line_account text; + v_line_debit numeric; + v_line_credit numeric; + v_line_currency text; + v_lines_total_debit numeric := 0; + v_lines_total_credit numeric := 0; + v_lines_bank_net numeric := 0; + v_sort_order int := 0; + + v_now timestamptz := now(); +BEGIN + -- Caller membership check (matches match_batch_allocate hardening). + IF NOT EXISTS ( + SELECT 1 FROM public.company_members + WHERE user_id = auth.uid() AND company_id = p_company_id + ) THEN + RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_UNAUTHORIZED'); + END IF; + + IF p_tx_ids IS NULL OR array_length(p_tx_ids, 1) IS NULL THEN + RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_NO_TXS'); + END IF; + + IF (p_existing_journal_entry_id IS NULL AND p_new_entry IS NULL) + OR (p_existing_journal_entry_id IS NOT NULL AND p_new_entry IS NOT NULL) THEN + RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_INVALID_PAYLOAD'); + END IF; + + -- Validate each tx + accumulate amount/date. Lock in id order for + -- deadlock stability. Reject early if any tx isn't eligible. + FOR v_tx IN + SELECT * FROM public.transactions + WHERE id = ANY(p_tx_ids) AND company_id = p_company_id + ORDER BY id + FOR UPDATE + LOOP + v_tx_count := v_tx_count + 1; + IF v_tx.journal_entry_id IS NOT NULL THEN + RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_TX_ALREADY_BOOKED', + 'details', jsonb_build_object('tx_id', v_tx.id)); + END IF; + -- Also reject txs that are already linked via the junction (from a + -- prior bulk-book that hasn't been undone). + IF EXISTS ( + SELECT 1 FROM public.transaction_voucher_links tvl + WHERE tvl.transaction_id = v_tx.id + ) THEN + RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_TX_ALREADY_BOOKED', + 'details', jsonb_build_object('tx_id', v_tx.id, 'via', 'transaction_voucher_links')); + END IF; + IF v_tx.amount = 0 THEN + RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_TX_ZERO_AMOUNT', + 'details', jsonb_build_object('tx_id', v_tx.id)); + END IF; + + -- All txs must share the same date (BFL gemensam-verifikation requires + -- same dag) and the same direction (an income tx and expense tx in + -- one batch would need offset booking, out of scope for v1). + IF v_tx_date IS NULL THEN + v_tx_date := v_tx.date; + v_direction := CASE WHEN v_tx.amount > 0 THEN 'income' ELSE 'expense' END; + ELSE + IF v_tx.date <> v_tx_date THEN + RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_DATE_MISMATCH', + 'details', jsonb_build_object('expected', v_tx_date, 'got', v_tx.date, 'tx_id', v_tx.id)); + END IF; + IF (v_tx.amount > 0 AND v_direction = 'expense') + OR (v_tx.amount < 0 AND v_direction = 'income') THEN + RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_DIRECTION_MISMATCH', + 'details', jsonb_build_object('tx_id', v_tx.id)); + END IF; + END IF; + + v_total_amount := v_total_amount + v_tx.amount; + END LOOP; + + IF v_tx_count = 0 THEN + RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_TXS_NOT_FOUND'); + END IF; + + IF v_tx_count <> array_length(p_tx_ids, 1) THEN + RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_TXS_NOT_FOUND', + 'details', jsonb_build_object('expected', array_length(p_tx_ids, 1), 'found', v_tx_count)); + END IF; + + v_total_amount_abs := ABS(v_total_amount); + + -- ── Branch A: link to existing verifikat ────────────────────────── + IF p_existing_journal_entry_id IS NOT NULL THEN + SELECT * INTO v_voucher + FROM public.journal_entries + WHERE id = p_existing_journal_entry_id AND company_id = p_company_id + FOR UPDATE; + + IF NOT FOUND THEN + RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_VOUCHER_NOT_FOUND'); + END IF; + + IF v_voucher.status <> 'posted' THEN + RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_VOUCHER_NOT_POSTED', + 'details', jsonb_build_object('status', v_voucher.status)); + END IF; + + -- Sum the 19xx net (debits − credits) on the existing voucher. + SELECT COALESCE(SUM(debit_amount - credit_amount), 0) INTO v_voucher_bank_net + FROM public.journal_entry_lines + WHERE journal_entry_id = p_existing_journal_entry_id + AND account_number >= '1900' AND account_number <= '1999'; + + IF v_voucher_bank_net = 0 THEN + RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_NO_BANK_LINE'); + END IF; + + -- The 19xx net must equal sum(tx.amount) — income txs are positive + -- (debit 1930), expense txs are negative (credit 1930). v_total_amount + -- carries the sign; v_voucher_bank_net (debit − credit) does too. + IF ABS(v_voucher_bank_net - v_total_amount) > 0.005 THEN + RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_AMOUNT_MISMATCH', + 'details', jsonb_build_object( + 'tx_sum', v_total_amount, + 'voucher_bank_net', v_voucher_bank_net + )); + END IF; + + -- Insert the junction rows. allocated_amount carries the tx's signed + -- amount; readers reconstruct per-tx contribution from this field. + FOR v_tx IN + SELECT * FROM public.transactions + WHERE id = ANY(p_tx_ids) AND company_id = p_company_id + ORDER BY id + LOOP + INSERT INTO public.transaction_voucher_links + (user_id, company_id, transaction_id, journal_entry_id, allocated_amount, role) + VALUES + (p_user_id, p_company_id, v_tx.id, p_existing_journal_entry_id, v_tx.amount, 'bank_line'); + END LOOP; + + -- For N=1: also set transactions.journal_entry_id so the existing 1:1 + -- reader path (inbox card, reconciliation status) keeps working. + IF v_tx_count = 1 THEN + UPDATE public.transactions + SET journal_entry_id = p_existing_journal_entry_id, + reconciliation_method = 'manual', + is_business = TRUE, + updated_at = v_now + WHERE id = p_tx_ids[1]; + ELSE + UPDATE public.transactions + SET is_business = TRUE, updated_at = v_now + WHERE id = ANY(p_tx_ids); + END IF; + + RETURN jsonb_build_object( + 'ok', true, + 'mode', 'link_existing', + 'journal_entry_id', p_existing_journal_entry_id, + 'voucher_series', v_voucher.voucher_series, + 'voucher_number', v_voucher.voucher_number, + 'linked_tx_count', v_tx_count, + 'tx_sum', v_total_amount + ); + END IF; + + -- ── Branch B: create new combined verifikat ─────────────────────── + -- p_new_entry shape: { description, lines: [{ account_number, debit_amount, + -- credit_amount, currency, + -- line_description?, sort_order? }] } + + v_entry_description := p_new_entry->>'description'; + IF v_entry_description IS NULL OR LENGTH(TRIM(v_entry_description)) = 0 THEN + RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_MISSING_DESCRIPTION'); + END IF; + + IF jsonb_typeof(p_new_entry->'lines') IS DISTINCT FROM 'array' + OR jsonb_array_length(p_new_entry->'lines') < 2 THEN + RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_NO_LINES'); + END IF; + + -- Sum debits/credits + 19xx net, verify balance + bank-leg match. + FOR v_line IN SELECT * FROM jsonb_array_elements(p_new_entry->'lines') + LOOP + v_line_account := v_line->>'account_number'; + v_line_debit := COALESCE((v_line->>'debit_amount')::numeric, 0); + v_line_credit := COALESCE((v_line->>'credit_amount')::numeric, 0); + IF v_line_debit < 0 OR v_line_credit < 0 THEN + RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_NEGATIVE_LINE', + 'details', jsonb_build_object('account', v_line_account)); + END IF; + IF v_line_debit > 0 AND v_line_credit > 0 THEN + RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_BOTH_SIDES_NONZERO', + 'details', jsonb_build_object('account', v_line_account)); + END IF; + v_lines_total_debit := v_lines_total_debit + v_line_debit; + v_lines_total_credit := v_lines_total_credit + v_line_credit; + IF v_line_account >= '1900' AND v_line_account <= '1999' THEN + v_lines_bank_net := v_lines_bank_net + v_line_debit - v_line_credit; + END IF; + END LOOP; + + IF ABS(v_lines_total_debit - v_lines_total_credit) > 0.005 THEN + RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_UNBALANCED', + 'details', jsonb_build_object( + 'debit_sum', v_lines_total_debit, 'credit_sum', v_lines_total_credit)); + END IF; + + IF ABS(v_lines_bank_net - v_total_amount) > 0.005 THEN + RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_AMOUNT_MISMATCH', + 'details', jsonb_build_object( + 'tx_sum', v_total_amount, + 'lines_bank_net', v_lines_bank_net)); + END IF; + + -- Resolve fiscal period for the (shared) tx date — ORDER BY DESC for + -- deterministic overlap resolution (same as match_batch_allocate). + SELECT id, is_closed, locked_at INTO v_fiscal_period_id, v_period_is_closed, v_period_locked_at + FROM public.fiscal_periods + WHERE company_id = p_company_id AND v_tx_date BETWEEN period_start AND period_end + ORDER BY period_start DESC LIMIT 1; + + IF v_fiscal_period_id IS NULL THEN + RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_NO_FISCAL_PERIOD', + 'details', jsonb_build_object('tx_date', v_tx_date)); + END IF; + + IF v_period_is_closed OR v_period_locked_at IS NOT NULL THEN + RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_PERIOD_LOCKED', + 'details', jsonb_build_object('fiscal_period_id', v_fiscal_period_id)); + END IF; + + v_journal_entry_id := gen_random_uuid(); + + INSERT INTO public.journal_entries + (id, user_id, company_id, fiscal_period_id, voucher_number, voucher_series, + entry_date, description, source_type, status) + VALUES + (v_journal_entry_id, p_user_id, p_company_id, v_fiscal_period_id, 0, v_voucher_series, + v_tx_date, v_entry_description, 'manual', 'draft'); + + -- Re-iterate lines in JSON order, preserving caller-supplied sort_order + -- when present, otherwise falling back to insertion order. + v_sort_order := 0; + FOR v_line IN SELECT * FROM jsonb_array_elements(p_new_entry->'lines') + LOOP + v_line_account := v_line->>'account_number'; + v_line_debit := COALESCE((v_line->>'debit_amount')::numeric, 0); + v_line_credit := COALESCE((v_line->>'credit_amount')::numeric, 0); + v_line_currency := COALESCE(v_line->>'currency', 'SEK'); + + INSERT INTO public.journal_entry_lines + (journal_entry_id, account_number, debit_amount, credit_amount, currency, + sort_order, line_description) + VALUES + (v_journal_entry_id, v_line_account, v_line_debit, v_line_credit, v_line_currency, + COALESCE((v_line->>'sort_order')::int, v_sort_order), + v_line->>'line_description'); + + v_sort_order := v_sort_order + 1; + END LOOP; + + -- Commit (assigns voucher_number, flips to 'posted', enforces period lock + -- + balance triggers). + SELECT voucher_number INTO v_voucher_number + FROM public.commit_journal_entry(p_company_id, v_journal_entry_id); + + -- Insert junction rows for each tx. + FOR v_tx IN + SELECT * FROM public.transactions + WHERE id = ANY(p_tx_ids) AND company_id = p_company_id + ORDER BY id + LOOP + INSERT INTO public.transaction_voucher_links + (user_id, company_id, transaction_id, journal_entry_id, allocated_amount, role) + VALUES + (p_user_id, p_company_id, v_tx.id, v_journal_entry_id, v_tx.amount, 'bank_line'); + END LOOP; + + IF v_tx_count = 1 THEN + UPDATE public.transactions + SET journal_entry_id = v_journal_entry_id, + is_business = TRUE, + updated_at = v_now + WHERE id = p_tx_ids[1]; + ELSE + UPDATE public.transactions + SET is_business = TRUE, updated_at = v_now + WHERE id = ANY(p_tx_ids); + END IF; + + RETURN jsonb_build_object( + 'ok', true, + 'mode', 'create_new', + 'journal_entry_id', v_journal_entry_id, + 'voucher_series', v_voucher_series, + 'voucher_number', v_voucher_number, + 'linked_tx_count', v_tx_count, + 'tx_sum', v_total_amount + ); +END; +$$; + +COMMENT ON FUNCTION public.bulk_book_transactions(uuid[], uuid, jsonb, uuid, uuid) IS + 'Bulk-book N bank transactions sharing the same date into a single combined verifikat (samlingsverifikation per BFL 5 kap 6§). Two branches: link to an existing posted verifikat, or create a new one from pre-computed lines (route does template expansion). Returns { ok, journal_entry_id, voucher_number, linked_tx_count, tx_sum } on success or { ok: false, code, details } on guard failure.'; + +NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/20260530130000_bulk_book_n1_reconciliation_method.sql b/supabase/migrations/20260530130000_bulk_book_n1_reconciliation_method.sql new file mode 100644 index 00000000..f019925a --- /dev/null +++ b/supabase/migrations/20260530130000_bulk_book_n1_reconciliation_method.sql @@ -0,0 +1,239 @@ +-- PR #606 review fix — set reconciliation_method='manual' on the N=1 +-- create-new branch of bulk_book_transactions so the two N=1 paths +-- (link-existing vs create-new) leave the transactions row in +-- equivalent state. Without this, downstream readers that filter on +-- reconciliation_method (reconciliation reports, status indicators) +-- would treat a single tx bulk-booked via the template path as +-- "unreconciled" while one linked to an existing voucher reads as +-- "manual". +-- +-- Only the create-new branch's final UPDATE changes; everything else is +-- byte-identical to 20260530120000_bulk_book_transactions.sql. + +CREATE OR REPLACE FUNCTION public.bulk_book_transactions( + p_tx_ids uuid[], + p_existing_journal_entry_id uuid, + p_new_entry jsonb, + p_user_id uuid, + p_company_id uuid +) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path TO 'public' +AS $$ +DECLARE + v_tx RECORD; + v_tx_date date; + v_total_amount numeric := 0; + v_total_amount_abs numeric; + v_direction text; + v_tx_count int := 0; + v_voucher RECORD; + v_voucher_bank_net numeric := 0; + v_fiscal_period_id uuid; + v_period_is_closed boolean; + v_period_locked_at timestamptz; + v_journal_entry_id uuid; + v_voucher_series text := 'A'; + v_voucher_number int; + v_entry_description text; + v_line jsonb; + v_line_account text; + v_line_debit numeric; + v_line_credit numeric; + v_line_currency text; + v_lines_total_debit numeric := 0; + v_lines_total_credit numeric := 0; + v_lines_bank_net numeric := 0; + v_sort_order int := 0; + v_now timestamptz := now(); +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM public.company_members + WHERE user_id = auth.uid() AND company_id = p_company_id + ) THEN + RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_UNAUTHORIZED'); + END IF; + IF p_tx_ids IS NULL OR array_length(p_tx_ids, 1) IS NULL THEN + RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_NO_TXS'); + END IF; + IF (p_existing_journal_entry_id IS NULL AND p_new_entry IS NULL) + OR (p_existing_journal_entry_id IS NOT NULL AND p_new_entry IS NOT NULL) THEN + RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_INVALID_PAYLOAD'); + END IF; + FOR v_tx IN + SELECT * FROM public.transactions + WHERE id = ANY(p_tx_ids) AND company_id = p_company_id + ORDER BY id FOR UPDATE + LOOP + v_tx_count := v_tx_count + 1; + IF v_tx.journal_entry_id IS NOT NULL THEN + RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_TX_ALREADY_BOOKED', + 'details', jsonb_build_object('tx_id', v_tx.id)); + END IF; + IF EXISTS ( + SELECT 1 FROM public.transaction_voucher_links tvl WHERE tvl.transaction_id = v_tx.id + ) THEN + RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_TX_ALREADY_BOOKED', + 'details', jsonb_build_object('tx_id', v_tx.id, 'via', 'transaction_voucher_links')); + END IF; + IF v_tx.amount = 0 THEN + RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_TX_ZERO_AMOUNT', + 'details', jsonb_build_object('tx_id', v_tx.id)); + END IF; + IF v_tx_date IS NULL THEN + v_tx_date := v_tx.date; + v_direction := CASE WHEN v_tx.amount > 0 THEN 'income' ELSE 'expense' END; + ELSE + IF v_tx.date <> v_tx_date THEN + RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_DATE_MISMATCH', + 'details', jsonb_build_object('expected', v_tx_date, 'got', v_tx.date, 'tx_id', v_tx.id)); + END IF; + IF (v_tx.amount > 0 AND v_direction = 'expense') + OR (v_tx.amount < 0 AND v_direction = 'income') THEN + RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_DIRECTION_MISMATCH', + 'details', jsonb_build_object('tx_id', v_tx.id)); + END IF; + END IF; + v_total_amount := v_total_amount + v_tx.amount; + END LOOP; + IF v_tx_count = 0 THEN + RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_TXS_NOT_FOUND'); + END IF; + IF v_tx_count <> array_length(p_tx_ids, 1) THEN + RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_TXS_NOT_FOUND', + 'details', jsonb_build_object('expected', array_length(p_tx_ids, 1), 'found', v_tx_count)); + END IF; + v_total_amount_abs := ABS(v_total_amount); + IF p_existing_journal_entry_id IS NOT NULL THEN + SELECT * INTO v_voucher FROM public.journal_entries + WHERE id = p_existing_journal_entry_id AND company_id = p_company_id FOR UPDATE; + IF NOT FOUND THEN + RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_VOUCHER_NOT_FOUND'); + END IF; + IF v_voucher.status <> 'posted' THEN + RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_VOUCHER_NOT_POSTED', + 'details', jsonb_build_object('status', v_voucher.status)); + END IF; + SELECT COALESCE(SUM(debit_amount - credit_amount), 0) INTO v_voucher_bank_net + FROM public.journal_entry_lines + WHERE journal_entry_id = p_existing_journal_entry_id + AND account_number >= '1900' AND account_number <= '1999'; + IF v_voucher_bank_net = 0 THEN + RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_NO_BANK_LINE'); + END IF; + IF ABS(v_voucher_bank_net - v_total_amount) > 0.005 THEN + RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_AMOUNT_MISMATCH', + 'details', jsonb_build_object('tx_sum', v_total_amount, 'voucher_bank_net', v_voucher_bank_net)); + END IF; + FOR v_tx IN + SELECT * FROM public.transactions + WHERE id = ANY(p_tx_ids) AND company_id = p_company_id ORDER BY id + LOOP + INSERT INTO public.transaction_voucher_links + (user_id, company_id, transaction_id, journal_entry_id, allocated_amount, role) + VALUES (p_user_id, p_company_id, v_tx.id, p_existing_journal_entry_id, v_tx.amount, 'bank_line'); + END LOOP; + IF v_tx_count = 1 THEN + UPDATE public.transactions SET journal_entry_id = p_existing_journal_entry_id, + reconciliation_method = 'manual', is_business = TRUE, updated_at = v_now WHERE id = p_tx_ids[1]; + ELSE + UPDATE public.transactions SET is_business = TRUE, updated_at = v_now WHERE id = ANY(p_tx_ids); + END IF; + RETURN jsonb_build_object('ok', true, 'mode', 'link_existing', + 'journal_entry_id', p_existing_journal_entry_id, + 'voucher_series', v_voucher.voucher_series, 'voucher_number', v_voucher.voucher_number, + 'linked_tx_count', v_tx_count, 'tx_sum', v_total_amount); + END IF; + v_entry_description := p_new_entry->>'description'; + IF v_entry_description IS NULL OR LENGTH(TRIM(v_entry_description)) = 0 THEN + RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_MISSING_DESCRIPTION'); + END IF; + IF jsonb_typeof(p_new_entry->'lines') IS DISTINCT FROM 'array' + OR jsonb_array_length(p_new_entry->'lines') < 2 THEN + RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_NO_LINES'); + END IF; + FOR v_line IN SELECT * FROM jsonb_array_elements(p_new_entry->'lines') LOOP + v_line_account := v_line->>'account_number'; + v_line_debit := COALESCE((v_line->>'debit_amount')::numeric, 0); + v_line_credit := COALESCE((v_line->>'credit_amount')::numeric, 0); + IF v_line_debit < 0 OR v_line_credit < 0 THEN + RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_NEGATIVE_LINE', + 'details', jsonb_build_object('account', v_line_account)); + END IF; + IF v_line_debit > 0 AND v_line_credit > 0 THEN + RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_BOTH_SIDES_NONZERO', + 'details', jsonb_build_object('account', v_line_account)); + END IF; + v_lines_total_debit := v_lines_total_debit + v_line_debit; + v_lines_total_credit := v_lines_total_credit + v_line_credit; + IF v_line_account >= '1900' AND v_line_account <= '1999' THEN + v_lines_bank_net := v_lines_bank_net + v_line_debit - v_line_credit; + END IF; + END LOOP; + IF ABS(v_lines_total_debit - v_lines_total_credit) > 0.005 THEN + RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_UNBALANCED', + 'details', jsonb_build_object('debit_sum', v_lines_total_debit, 'credit_sum', v_lines_total_credit)); + END IF; + IF ABS(v_lines_bank_net - v_total_amount) > 0.005 THEN + RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_AMOUNT_MISMATCH', + 'details', jsonb_build_object('tx_sum', v_total_amount, 'lines_bank_net', v_lines_bank_net)); + END IF; + SELECT id, is_closed, locked_at INTO v_fiscal_period_id, v_period_is_closed, v_period_locked_at + FROM public.fiscal_periods + WHERE company_id = p_company_id AND v_tx_date BETWEEN period_start AND period_end + ORDER BY period_start DESC LIMIT 1; + IF v_fiscal_period_id IS NULL THEN + RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_NO_FISCAL_PERIOD', + 'details', jsonb_build_object('tx_date', v_tx_date)); + END IF; + IF v_period_is_closed OR v_period_locked_at IS NOT NULL THEN + RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_PERIOD_LOCKED', + 'details', jsonb_build_object('fiscal_period_id', v_fiscal_period_id)); + END IF; + v_journal_entry_id := gen_random_uuid(); + INSERT INTO public.journal_entries + (id, user_id, company_id, fiscal_period_id, voucher_number, voucher_series, + entry_date, description, source_type, status) + VALUES + (v_journal_entry_id, p_user_id, p_company_id, v_fiscal_period_id, 0, v_voucher_series, + v_tx_date, v_entry_description, 'manual', 'draft'); + v_sort_order := 0; + FOR v_line IN SELECT * FROM jsonb_array_elements(p_new_entry->'lines') LOOP + v_line_account := v_line->>'account_number'; + v_line_debit := COALESCE((v_line->>'debit_amount')::numeric, 0); + v_line_credit := COALESCE((v_line->>'credit_amount')::numeric, 0); + v_line_currency := COALESCE(v_line->>'currency', 'SEK'); + INSERT INTO public.journal_entry_lines + (journal_entry_id, account_number, debit_amount, credit_amount, currency, sort_order, line_description) + VALUES + (v_journal_entry_id, v_line_account, v_line_debit, v_line_credit, v_line_currency, + COALESCE((v_line->>'sort_order')::int, v_sort_order), v_line->>'line_description'); + v_sort_order := v_sort_order + 1; + END LOOP; + SELECT voucher_number INTO v_voucher_number FROM public.commit_journal_entry(p_company_id, v_journal_entry_id); + FOR v_tx IN + SELECT * FROM public.transactions + WHERE id = ANY(p_tx_ids) AND company_id = p_company_id ORDER BY id + LOOP + INSERT INTO public.transaction_voucher_links + (user_id, company_id, transaction_id, journal_entry_id, allocated_amount, role) + VALUES (p_user_id, p_company_id, v_tx.id, v_journal_entry_id, v_tx.amount, 'bank_line'); + END LOOP; + -- PR #606 review fix: branch B N=1 now sets reconciliation_method='manual' + -- so the two N=1 paths leave the row in equivalent state. + IF v_tx_count = 1 THEN + UPDATE public.transactions SET journal_entry_id = v_journal_entry_id, + reconciliation_method = 'manual', is_business = TRUE, updated_at = v_now WHERE id = p_tx_ids[1]; + ELSE + UPDATE public.transactions SET is_business = TRUE, updated_at = v_now WHERE id = ANY(p_tx_ids); + END IF; + RETURN jsonb_build_object('ok', true, 'mode', 'create_new', + 'journal_entry_id', v_journal_entry_id, + 'voucher_series', v_voucher_series, 'voucher_number', v_voucher_number, + 'linked_tx_count', v_tx_count, 'tx_sum', v_total_amount); +END; +$$; + +NOTIFY pgrst, 'reload schema'; diff --git a/tests/pg/bulk-book-transactions.pg.test.ts b/tests/pg/bulk-book-transactions.pg.test.ts new file mode 100644 index 00000000..3b6b5763 --- /dev/null +++ b/tests/pg/bulk-book-transactions.pg.test.ts @@ -0,0 +1,321 @@ +import { randomUUID } from 'node:crypto' +import { describe, expect, it } from 'vitest' +import { + insertAuthUser, + insertCompany, + insertCompanyMember, + insertFiscalPeriod, +} from '@/tests/pg/fixtures' +import { getPool, withUserContext } from '@/tests/pg/setup' + +/** + * Covers 20260530120000_bulk_book_transactions: + * + * - Happy path create-new: 3 income txs on the same day → one + * combined verifikat (samlingsverifikation) with the caller-supplied + * lines. transaction_voucher_links populated. Bank net equals tx sum. + * + * - Happy path link-existing: 3 txs linked to an already-posted manual + * day-summary verifikat. Just inserts junction rows. + * + * - Guard codes: date mismatch, direction mismatch, already-booked tx, + * amount mismatch, unbalanced lines, no-bank-line, unauthorized. + */ + +async function insertTransaction(params: { + userId: string + companyId: string + amount: number + date?: string + currency?: string +}): Promise { + const id = randomUUID() + await getPool().query( + `INSERT INTO public.transactions + (id, user_id, company_id, date, description, amount, currency, category) + VALUES ($1, $2, $3, $4, 'Bank tx', $5, $6, 'uncategorized')`, + [id, params.userId, params.companyId, params.date ?? '2026-06-05', params.amount, params.currency ?? 'SEK'], + ) + return id +} + +async function seedTenant() { + const userId = await insertAuthUser() + const companyId = await insertCompany({ createdBy: userId }) + await insertCompanyMember({ companyId, userId, role: 'owner' }) + const fiscalPeriodId = await insertFiscalPeriod({ + userId, + companyId, + periodStart: '2026-01-01', + periodEnd: '2026-12-31', + }) + return { userId, companyId, fiscalPeriodId } +} + +interface RpcResult { + ok: boolean + code?: string + details?: Record + mode?: 'link_existing' | 'create_new' + journal_entry_id?: string + voucher_number?: number + linked_tx_count?: number + tx_sum?: number +} + +describe('bulk_book_transactions — create new', () => { + it('builds a single combined verifikat from caller-supplied lines (kiosk samlingsverifikation)', async () => { + const { userId, companyId } = await seedTenant() + // 3 income txs at 100/200/300 SEK on the same day. + const tx1 = await insertTransaction({ userId, companyId, amount: 100 }) + const tx2 = await insertTransaction({ userId, companyId, amount: 200 }) + const tx3 = await insertTransaction({ userId, companyId, amount: 300 }) + + // Pre-computed lines (route-side template expansion in TS). + // Total: 600 SEK. 25% VAT split: 480 net + 120 VAT. + const newEntry = { + description: 'Samlingsverifikation kiosk 2026-06-05', + lines: [ + { account_number: '1930', debit_amount: 600, credit_amount: 0, currency: 'SEK', line_description: 'Inbetalningar Swish' }, + { account_number: '3001', debit_amount: 0, credit_amount: 480, currency: 'SEK', line_description: 'Försäljning' }, + { account_number: '2611', debit_amount: 0, credit_amount: 120, currency: 'SEK', line_description: 'Utgående moms 25%' }, + ], + } + + await withUserContext(userId, async (client) => { + const r = await client.query<{ bulk_book_transactions: RpcResult }>( + `SELECT bulk_book_transactions($1::uuid[], $2, $3::jsonb, $4, $5)`, + [[tx1, tx2, tx3], null, JSON.stringify(newEntry), userId, companyId], + ) + const result = r.rows[0]!.bulk_book_transactions + expect(result.ok).toBe(true) + expect(result.mode).toBe('create_new') + expect(result.journal_entry_id).toBeTruthy() + expect(result.linked_tx_count).toBe(3) + expect(result.tx_sum).toBe(600) + + // Verify lines on the new verifikat. + const lines = await client.query<{ account_number: string; debit_amount: string; credit_amount: string }>( + `SELECT account_number, debit_amount, credit_amount FROM public.journal_entry_lines + WHERE journal_entry_id = $1 ORDER BY sort_order`, + [result.journal_entry_id], + ) + expect(lines.rows).toHaveLength(3) + const bankLine = lines.rows.find((l) => l.account_number === '1930') + expect(Number(bankLine!.debit_amount)).toBe(600) + + // Verify 3 transaction_voucher_links rows pointing at the same JE. + const links = await client.query<{ allocated_amount: string; transaction_id: string }>( + `SELECT allocated_amount, transaction_id FROM public.transaction_voucher_links + WHERE journal_entry_id = $1`, + [result.journal_entry_id], + ) + expect(links.rows).toHaveLength(3) + const linkedTxIds = new Set(links.rows.map((l) => l.transaction_id)) + expect(linkedTxIds).toEqual(new Set([tx1, tx2, tx3])) + + // For N>1, transactions.journal_entry_id is NOT set on the individual rows. + const txRow1 = await client.query<{ journal_entry_id: string | null; is_business: boolean }>( + `SELECT journal_entry_id, is_business FROM public.transactions WHERE id = $1`, + [tx1], + ) + expect(txRow1.rows[0]!.journal_entry_id).toBeNull() + expect(txRow1.rows[0]!.is_business).toBe(true) + }) + }) + + it('rejects BULK_BOOK_DATE_MISMATCH when txs span multiple dates', async () => { + const { userId, companyId } = await seedTenant() + const tx1 = await insertTransaction({ userId, companyId, amount: 100, date: '2026-06-05' }) + const tx2 = await insertTransaction({ userId, companyId, amount: 100, date: '2026-06-06' }) + + const newEntry = { + description: 'Test', + lines: [ + { account_number: '1930', debit_amount: 200, credit_amount: 0, currency: 'SEK' }, + { account_number: '3001', debit_amount: 0, credit_amount: 200, currency: 'SEK' }, + ], + } + + await withUserContext(userId, async (client) => { + const r = await client.query<{ bulk_book_transactions: RpcResult }>( + `SELECT bulk_book_transactions($1::uuid[], $2, $3::jsonb, $4, $5)`, + [[tx1, tx2], null, JSON.stringify(newEntry), userId, companyId], + ) + const result = r.rows[0]!.bulk_book_transactions + expect(result.ok).toBe(false) + expect(result.code).toBe('BULK_BOOK_DATE_MISMATCH') + }) + }) + + it('rejects BULK_BOOK_DIRECTION_MISMATCH when income + expense txs are mixed', async () => { + const { userId, companyId } = await seedTenant() + const tx1 = await insertTransaction({ userId, companyId, amount: 100 }) + const tx2 = await insertTransaction({ userId, companyId, amount: -100 }) + + const newEntry = { + description: 'Test', + lines: [ + { account_number: '1930', debit_amount: 0, credit_amount: 0, currency: 'SEK' }, + ], + } + + await withUserContext(userId, async (client) => { + const r = await client.query<{ bulk_book_transactions: RpcResult }>( + `SELECT bulk_book_transactions($1::uuid[], $2, $3::jsonb, $4, $5)`, + [[tx1, tx2], null, JSON.stringify(newEntry), userId, companyId], + ) + const result = r.rows[0]!.bulk_book_transactions + expect(result.ok).toBe(false) + expect(result.code).toBe('BULK_BOOK_DIRECTION_MISMATCH') + }) + }) + + it('rejects BULK_BOOK_AMOUNT_MISMATCH when bank-line net does not equal tx sum', async () => { + const { userId, companyId } = await seedTenant() + const tx1 = await insertTransaction({ userId, companyId, amount: 100 }) + const tx2 = await insertTransaction({ userId, companyId, amount: 200 }) + + // Caller claims 500 SEK net on 1930 but txs sum to 300. + const newEntry = { + description: 'Test', + lines: [ + { account_number: '1930', debit_amount: 500, credit_amount: 0, currency: 'SEK' }, + { account_number: '3001', debit_amount: 0, credit_amount: 500, currency: 'SEK' }, + ], + } + + await withUserContext(userId, async (client) => { + const r = await client.query<{ bulk_book_transactions: RpcResult }>( + `SELECT bulk_book_transactions($1::uuid[], $2, $3::jsonb, $4, $5)`, + [[tx1, tx2], null, JSON.stringify(newEntry), userId, companyId], + ) + const result = r.rows[0]!.bulk_book_transactions + expect(result.ok).toBe(false) + expect(result.code).toBe('BULK_BOOK_AMOUNT_MISMATCH') + }) + }) + + it('rejects BULK_BOOK_UNBALANCED when debits do not equal credits', async () => { + const { userId, companyId } = await seedTenant() + const tx1 = await insertTransaction({ userId, companyId, amount: 100 }) + + const newEntry = { + description: 'Test', + lines: [ + { account_number: '1930', debit_amount: 100, credit_amount: 0, currency: 'SEK' }, + { account_number: '3001', debit_amount: 0, credit_amount: 90, currency: 'SEK' }, + ], + } + + await withUserContext(userId, async (client) => { + const r = await client.query<{ bulk_book_transactions: RpcResult }>( + `SELECT bulk_book_transactions($1::uuid[], $2, $3::jsonb, $4, $5)`, + [[tx1], null, JSON.stringify(newEntry), userId, companyId], + ) + const result = r.rows[0]!.bulk_book_transactions + expect(result.ok).toBe(false) + expect(result.code).toBe('BULK_BOOK_UNBALANCED') + }) + }) +}) + +describe('bulk_book_transactions — link existing', () => { + it('links N txs to an already-posted day-summary verifikat', async () => { + const { userId, companyId, fiscalPeriodId } = await seedTenant() + const tx1 = await insertTransaction({ userId, companyId, amount: 100 }) + const tx2 = await insertTransaction({ userId, companyId, amount: 200 }) + + // Pre-create a posted manual verifikat with the right bank net (+300). + const jeId = 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) + VALUES ($1, $2, $3, $4, 1, 'A', '2026-06-05', 'Manual dagssumma', 'manual', 'draft')`, + [jeId, userId, companyId, fiscalPeriodId], + ) + await getPool().query( + `INSERT INTO public.journal_entry_lines (journal_entry_id, account_number, debit_amount, credit_amount) + VALUES ($1, '1930', 300, 0), ($1, '3001', 0, 240), ($1, '2611', 0, 60)`, + [jeId], + ) + await getPool().query(`UPDATE public.journal_entries SET status = 'posted' WHERE id = $1`, [jeId]) + + await withUserContext(userId, async (client) => { + const r = await client.query<{ bulk_book_transactions: RpcResult }>( + `SELECT bulk_book_transactions($1::uuid[], $2, $3, $4, $5)`, + [[tx1, tx2], jeId, null, userId, companyId], + ) + const result = r.rows[0]!.bulk_book_transactions + expect(result.ok).toBe(true) + expect(result.mode).toBe('link_existing') + expect(result.journal_entry_id).toBe(jeId) + expect(result.linked_tx_count).toBe(2) + + // No new JE was created — only junction rows. + const links = await client.query<{ transaction_id: string }>( + `SELECT transaction_id FROM public.transaction_voucher_links + WHERE journal_entry_id = $1`, + [jeId], + ) + expect(links.rows).toHaveLength(2) + }) + }) + + it('rejects link with BULK_BOOK_AMOUNT_MISMATCH when bank net does not equal tx sum', async () => { + const { userId, companyId, fiscalPeriodId } = await seedTenant() + const tx1 = await insertTransaction({ userId, companyId, amount: 100 }) + const tx2 = await insertTransaction({ userId, companyId, amount: 200 }) + + // JE bank net = +400 but txs sum to 300. + const jeId = 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) + VALUES ($1, $2, $3, $4, 1, 'A', '2026-06-05', 'Manual', 'manual', 'draft')`, + [jeId, userId, companyId, fiscalPeriodId], + ) + await getPool().query( + `INSERT INTO public.journal_entry_lines (journal_entry_id, account_number, debit_amount, credit_amount) + VALUES ($1, '1930', 400, 0), ($1, '3001', 0, 400)`, + [jeId], + ) + await getPool().query(`UPDATE public.journal_entries SET status = 'posted' WHERE id = $1`, [jeId]) + + await withUserContext(userId, async (client) => { + const r = await client.query<{ bulk_book_transactions: RpcResult }>( + `SELECT bulk_book_transactions($1::uuid[], $2, $3, $4, $5)`, + [[tx1, tx2], jeId, null, userId, companyId], + ) + const result = r.rows[0]!.bulk_book_transactions + expect(result.ok).toBe(false) + expect(result.code).toBe('BULK_BOOK_AMOUNT_MISMATCH') + }) + }) + + it('rejects BULK_BOOK_UNAUTHORIZED when caller is not a company member', async () => { + const { userId, companyId } = await seedTenant() + const tx1 = await insertTransaction({ userId, companyId, amount: 100 }) + + const outsiderId = await insertAuthUser() + const newEntry = { + description: 'Test', + lines: [ + { account_number: '1930', debit_amount: 100, credit_amount: 0, currency: 'SEK' }, + { account_number: '3001', debit_amount: 0, credit_amount: 100, currency: 'SEK' }, + ], + } + + await withUserContext(outsiderId, async (client) => { + const r = await client.query<{ bulk_book_transactions: RpcResult }>( + `SELECT bulk_book_transactions($1::uuid[], $2, $3::jsonb, $4, $5)`, + [[tx1], null, JSON.stringify(newEntry), outsiderId, companyId], + ) + const result = r.rows[0]!.bulk_book_transactions + expect(result.ok).toBe(false) + expect(result.code).toBe('BULK_BOOK_UNAUTHORIZED') + }) + }) +})