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>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
6629964780
commit
4da87e5e4c
@@ -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<TransactionWithInvoice | null>(null)
|
||||
const [splitMatchOpen, setSplitMatchOpen] = useState(false)
|
||||
const [splitMatchTransaction, setSplitMatchTransaction] = useState<TransactionWithInvoice | null>(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() {
|
||||
<Trash2 className="mr-1 h-3 w-3" />
|
||||
Ta bort
|
||||
</Button>
|
||||
{/* Bulk-book (samlingsverifikation) — only when ≥2 selected on
|
||||
the same date + same direction. Disabled state explains why
|
||||
via title. */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setBulkBookOpen(true)}
|
||||
disabled={!bulkBookEligible}
|
||||
title={
|
||||
!bulkBookEligible
|
||||
? 'Välj minst två transaktioner från samma datum och samma riktning'
|
||||
: 'Skapa en samlingsverifikation för de valda transaktionerna'
|
||||
}
|
||||
>
|
||||
<Layers className="mr-1 h-3 w-3" />
|
||||
Bokför i klump
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setShowBatchSelector(true)}>
|
||||
Bokför
|
||||
</Button>
|
||||
@@ -1835,6 +1892,13 @@ export default function TransactionsPage() {
|
||||
onSuccess={handleSplitMatchSuccess}
|
||||
/>
|
||||
|
||||
<BulkBookDialog
|
||||
open={bulkBookOpen}
|
||||
onOpenChange={setBulkBookOpen}
|
||||
transactions={selectedTransactions}
|
||||
onSuccess={handleBulkBookSuccess}
|
||||
/>
|
||||
|
||||
<TransactionBookingDialog
|
||||
open={bookingDialogOpen}
|
||||
onOpenChange={(o) => {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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<string, unknown>
|
||||
}
|
||||
|
||||
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<Transaction, 'id' | 'amount' | 'currency' | 'description' | 'date'>[]
|
||||
|
||||
// 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 },
|
||||
)
|
||||
Reference in New Issue
Block a user