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