feat(transactions): split-payment allocator — 1 tx → N invoices (#603)
* fix(category-mapping): use leaf BAS accounts instead of group codes 3900, 5800, 6200 are BAS gruppkonton (header codes) and shouldn't carry postings. Switched the default mappings to the matching leaf accounts: - income_other: 3900 -> 3999 (Övriga rörelseintäkter) - expense_travel: 5800 -> 5890 (Övriga resekostnader) - expense_telecom: 6200 -> 6230 (Datakommunikation) The fallback for income_other inside getCategoryAccountMapping was also hardcoded to '3900'; updated to '3999' for consistency. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(transactions): split-payment allocator — 1 tx → N invoices Closes one of the two flows that motivated PR #602's foundation: allocating a single bank transaction across multiple customer OR multiple supplier invoices, with one combined verifikat (samlingsverifikation per BFL 5 kap 6§ st 3). ## Backend (Phase 3a) - **PL/pgSQL RPC** match_batch_allocate (~400 lines): locks the tx + each target invoice with SELECT … FOR UPDATE in id order, validates status/currency/remaining/direction before any write, builds the combined verifikat via commit_journal_entry (atomically assigns voucher_number + flips draft→posted), inserts N rows in invoice_payments or supplier_invoice_payments pointing at the same JE, advances paid_amount/remaining_amount/status per invoice. Returns { ok, journal_entry_id, voucher_number, allocations: [...] } on success or { ok: false, code, details } on guard failure. Mixed customer+supplier kinds are rejected (v1 scope). - **Endpoint** POST /api/transactions/[id]/match-batch — thin wrapper around the RPC. Validates body via MatchBatchSchema (zod discriminatedUnion + superRefine to catch mixed-kinds at the schema layer). On RPC success, emits one invoice.match_confirmed or supplier_invoice.match_confirmed event per allocation so existing subscribers (reminders, automations, processing-history) keep working. Maps the structured RPC error envelope to errorResponseFromCode. - **16 new BATCH_* error codes** (sv+en): BATCH_TX_NOT_FOUND, BATCH_TX_ALREADY_BOOKED, BATCH_OVERSHOOT, BATCH_AMOUNT_EXCEEDS_TX, BATCH_MIXED_KINDS_UNSUPPORTED, BATCH_DIRECTION_MISMATCH, BATCH_CURRENCY_MISMATCH, BATCH_PERIOD_LOCKED, BATCH_RPC_FAILED, etc. ## UI (Phase 5a) - **MatchAllocationDialog** (components/transactions/) — direction- aware (positive tx → customer invoices, negative → supplier). Search + selectable list of open invoices. Per-row amount input with default = min(invoice.remaining, tx_remaining_budget). Live tally with green-check balanced state, red overshoot warning, gray leftover note. Confirm button disabled on overshoot. POSTs to /match-batch and on 200 triggers the same exit animation as single-tx match. - **Inbox row** gains a second outline icon button (Split icon) next to the existing 1:1 match button, gated by the same showInvoiceMatchButton predicate. Tooltip explains the direction- aware split. Opens MatchAllocationDialog. - **i18n** strings under tx_match_allocation namespace in sv.json and en.json (32 keys each). ## Tests - tests/pg/match-batch-allocate.pg.test.ts — 5 pg-real tests covering combined verifikat shape, overshoot guard, already-booked tx, direction mismatch, mixed-kinds rejection. - app/api/transactions/[id]/match-batch/__tests__/route.test.ts — 5 unit tests covering schema validation, mixed-kinds, happy path, structured-error mapping, raw-error → BATCH_RPC_FAILED. 63 unit tests pass across the touched paths. The RPC migration was already applied to remote in an earlier Phase 3a session (idempotent CREATE OR REPLACE FUNCTION; the next replay is a no-op). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(match-batch): PR #603 review round 1 + CI fixes Closes both CI failures and the three real review findings. ## CI fixes - **pg-real failure**: the RPC declared `v_journal_entry_id uuid := uuid_generate_v4()` which fails in the CI Postgres image (uuid-ossp extension is off). Switched to `gen_random_uuid()` — the codebase standard already used by supplier_invoices, invoice_inbox, etc. - **core-only failure**: my earlier BAS leaf-account commit (3900→3999, 5800→5890, 6200→6230) didn't update the matching `lib/bookkeeping/__tests__/category-mapping.test.ts` expectations, and `getDefaultAccountForCategory`'s fallback for `income_*` was still hardcoded to '3900'. Updated both. ## Review findings (greptile) - **P1 deadlock-stable locking** (`match_batch_allocate.sql:11`): the validation `FOR UPDATE` loop ran in caller-supplied array order. Two concurrent calls with overlapping invoice sets in opposite orders could deadlock and one would abort with `BATCH_RPC_FAILED`. Now all three loops (validate, build lines, advance invoices) iterate via `SELECT … FROM jsonb_array_elements(…) ORDER BY COALESCE(invoice_id, supplier_invoice_id)`, giving a stable global lock order regardless of how the caller ordered the JSON array. - **P1 duplicate-allocation detection** (`match_batch_allocate.sql:163`): the same invoice_id listed twice would pass the per-row overshoot guard (both iterations read the original `remaining_amount`) and the write loop would insert two `invoice_payments` rows for the same invoice. Added a `v_seen_ids text[]` check in the validation loop and a new `BATCH_DUPLICATE_ALLOCATION` error code (sv + en). The dialog already prevents this UI-side via `if (prev[candidate.id] return prev` — the RPC guard is the defense-in-depth layer. - **P2 zod `.positive()`** (`schemas.ts:544`): allocation amount was `nonNegativeAmount` (allowing 0), passing schema validation only to be rejected by the RPC with `BATCH_INVALID_AMOUNT`. Now `z.number().positive(…)` so 0-amount entries fail at the schema layer with a per-field path, cleaner 400. - **P2 strict `> 0` direction check** (`MatchAllocationDialog.tsx:82`): used `amount >= 0` to pick customer-side, but a zero-amount tx would load customer candidates only to hit `BATCH_TX_ZERO_AMOUNT` at submit time after the user has filled in allocations. Switched to `> 0` so 0-amount tx never reaches the dialog at all (it's rejected by the RPC immediately). The fourth Greptile comment (the schema P2 about amount validation) overlaps with the third; addressed in the same edit. ## Verification - 112 unit tests pass across touched paths - ESLint clean - New pg-real test `tests/pg/match-batch-allocate.pg.test.ts` covers the dedupe scenario (same supplier invoice listed twice with summing amounts that individually pass per-row overshoot) - RPC patch applied to remote via Supabase MCP Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(match-batch): PR #603 review round 2 — compliance hardening Addresses the actionable findings from compliance-swarm and Swedish-accounting-compliance reviews. Six small RPC changes + two TS-side guards, all bundled in one follow-up migration. ## Security - **(GDPR Art.5(1)(f) / ISO A.8.2) Caller verification**: SECURITY DEFINER bypasses RLS, and the prior RPC accepted any (p_user_id, p_company_id) pair from the route. Now the function rejects with new `BATCH_UNAUTHORIZED` (sv+en, HTTP 403) if `auth.uid()` is not a member of `p_company_id`. Pattern lifted from `harden_invoice_number_rpcs` (#20260510140000). - **(OWASP V4.2) Allocation cap**: `MatchBatchSchema.allocations` now carries `.max(100)` to prevent DoS via unbounded FOR UPDATE locks. ## Swedish accounting correctness - **source_type per direction**: was hardcoded to `'invoice_paid'` for both customer + supplier batches, mis-routing behandlingshistorik filters. Customer batches keep `'invoice_paid'`, supplier batches now write `'supplier_invoice_paid'`. - **Fiscal-period determinism**: `LIMIT 1` on the period lookup was non-deterministic on overlap (e.g. corrected broken year). Added `ORDER BY period_start DESC` so the most recent matching period wins. - **Tolerance harmonisation**: cross-allocation sum used `+0.01` tolerance while per-row used `+0.005`. Both now `+0.005` so a multi-row batch can't drift ~0.01 SEK while each row passes individually. - **`transactions.category` no longer overwritten**: was forced to `'income_services'` (→ BAS 3001 at 25% VAT) for any customer batch, misrepresenting reduced-rate / export / EU-service invoices. The category is only meaningful 1:1 with a single invoice; batches now leave it as-is, mirroring the supplier-side `ELSE category` branch. ## Tests - `tests/pg/match-batch-allocate.pg.test.ts` now wraps every RPC call in `withUserContext(userId)` so `auth.uid()` resolves to the seeded owner. Without this the new membership check would have failed all existing tests. - New pg-real test: `rejects with BATCH_UNAUTHORIZED when caller is not a member of the company` — outsider user gets explicit refusal. - New happy-path assertion: `source_type = 'supplier_invoice_paid'` on the combined verifikat for supplier batches. 15 unit tests pass on the touched paths. RPC patch applied to remote via Supabase MCP. Out-of-scope mcp-server changes still parked locally. Skipped findings (documented in PR comment thread): - V8.2.1 ownership pre-check at route layer (RPC enforces it) - V4.5 / Art.5(1)(b) narrower API response and event payload — typed contracts require the full shapes - V2.4 rate-limiting — system-level, applies to all match endpoints - A.8.28 client-side RLS reliance — documented architectural choice - Direction pre-check at API layer (RPC catches with cleaner code) - V16 + Art.32 + Art.5(1)(b) low-severity logging nits Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
7bcd46d503
commit
7eb8715417
@@ -33,6 +33,7 @@ import { SkattekontoMatchDialog } from '@/components/skattekonto/SkattekontoMatc
|
||||
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 TransactionBookingDialog from '@/components/transactions/TransactionBookingDialog'
|
||||
import QuickReviewDialog from '@/components/transactions/QuickReviewDialog'
|
||||
|
||||
@@ -120,6 +121,8 @@ export default function TransactionsPage() {
|
||||
const [invoicePickerTransaction, setInvoicePickerTransaction] = useState<TransactionWithInvoice | null>(null)
|
||||
const [supplierInvoicePickerOpen, setSupplierInvoicePickerOpen] = useState(false)
|
||||
const [supplierInvoicePickerTransaction, setSupplierInvoicePickerTransaction] = useState<TransactionWithInvoice | null>(null)
|
||||
const [splitMatchOpen, setSplitMatchOpen] = useState(false)
|
||||
const [splitMatchTransaction, setSplitMatchTransaction] = useState<TransactionWithInvoice | null>(null)
|
||||
const [isMatchingSupplierFromPicker, setIsMatchingSupplierFromPicker] = useState(false)
|
||||
const [isMatchingFromPicker, setIsMatchingFromPicker] = useState(false)
|
||||
|
||||
@@ -1182,6 +1185,29 @@ export default function TransactionsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
function openSplitMatchDialog(transaction: TransactionWithInvoice) {
|
||||
setSplitMatchTransaction(transaction)
|
||||
setSplitMatchOpen(true)
|
||||
}
|
||||
|
||||
async function handleSplitMatchSuccess() {
|
||||
if (!splitMatchTransaction) return
|
||||
const txId = splitMatchTransaction.id
|
||||
// Mark the tx as exiting to trigger the same removal animation the
|
||||
// single-match flow uses, then drop it from the inbox once the refetch
|
||||
// confirms it's booked. Mirrors the pattern at the supplier-invoice
|
||||
// match success path below.
|
||||
setExitingIds((prev) => new Set(prev).add(txId))
|
||||
await fetchTransactions()
|
||||
setTimeout(() => {
|
||||
setExitingIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(txId)
|
||||
return next
|
||||
})
|
||||
}, 350)
|
||||
}
|
||||
|
||||
async function handleCreateTransaction(data: CreateTransactionInput) {
|
||||
setIsCreating(true)
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
@@ -1710,6 +1736,7 @@ export default function TransactionsPage() {
|
||||
onCategorize={handleCategorize}
|
||||
onOpenMatchDialog={openMatchDialog}
|
||||
onOpenMatchInvoicePicker={openInvoiceMatchPicker}
|
||||
onOpenSplitMatch={openSplitMatchDialog}
|
||||
onOpenCategoryDialog={openCategoryDialog}
|
||||
onDelete={handleDeleteTransaction}
|
||||
onToggleSelect={toggleBatchSelect}
|
||||
@@ -1798,6 +1825,16 @@ export default function TransactionsPage() {
|
||||
onLinkToExisting={handleLinkToExistingVoucher}
|
||||
/>
|
||||
|
||||
<MatchAllocationDialog
|
||||
open={splitMatchOpen}
|
||||
onOpenChange={(o) => {
|
||||
setSplitMatchOpen(o)
|
||||
if (!o) setSplitMatchTransaction(null)
|
||||
}}
|
||||
transaction={splitMatchTransaction}
|
||||
onSuccess={handleSplitMatchSuccess}
|
||||
/>
|
||||
|
||||
<TransactionBookingDialog
|
||||
open={bookingDialogOpen}
|
||||
onOpenChange={(o) => {
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import {
|
||||
createMockRequest,
|
||||
createMockRouteParams,
|
||||
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 }),
|
||||
}))
|
||||
|
||||
import { POST } from '../route'
|
||||
|
||||
const TX_UUID = '11111111-1111-4111-8111-111111111111'
|
||||
const INV_UUID = '22222222-2222-4222-8222-222222222222'
|
||||
const SI_UUID = '33333333-3333-4333-8333-333333333333'
|
||||
|
||||
describe('POST /api/transactions/[id]/match-batch', () => {
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
|
||||
})
|
||||
|
||||
it('returns 400 when allocations is missing', async () => {
|
||||
const request = createMockRequest(`/api/transactions/${TX_UUID}/match-batch`, {
|
||||
method: 'POST',
|
||||
body: {},
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: TX_UUID }))
|
||||
expect(response.status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns 400 when allocations mix customer and supplier kinds', async () => {
|
||||
const request = createMockRequest(`/api/transactions/${TX_UUID}/match-batch`, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
allocations: [
|
||||
{ kind: 'customer_invoice', invoice_id: INV_UUID, amount: 500 },
|
||||
{ kind: 'supplier_invoice', supplier_invoice_id: SI_UUID, amount: 500 },
|
||||
],
|
||||
},
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: TX_UUID }))
|
||||
expect(response.status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns 200 with the RPC result on the happy path', async () => {
|
||||
// RPC returns success envelope
|
||||
enqueue({
|
||||
data: {
|
||||
ok: true,
|
||||
journal_entry_id: 'je-batch-1',
|
||||
voucher_series: 'A',
|
||||
voucher_number: 12,
|
||||
tx_id: TX_UUID,
|
||||
allocations: [
|
||||
{
|
||||
kind: 'customer_invoice',
|
||||
invoice_id: INV_UUID,
|
||||
payment_id: 'ip-1',
|
||||
status: 'paid',
|
||||
paid_amount: 1000,
|
||||
remaining_amount: 0,
|
||||
amount: 1000,
|
||||
},
|
||||
],
|
||||
total_allocated: 1000,
|
||||
leftover: 0,
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
// tx fetch for event payload
|
||||
enqueue({ data: { id: TX_UUID, amount: 1000, currency: 'SEK' }, error: null })
|
||||
// invoice fetch for event payload
|
||||
enqueue({ data: { id: INV_UUID, currency: 'SEK', status: 'paid' }, error: null })
|
||||
|
||||
const request = createMockRequest(`/api/transactions/${TX_UUID}/match-batch`, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
allocations: [{ kind: 'customer_invoice', invoice_id: INV_UUID, amount: 1000 }],
|
||||
},
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: TX_UUID }))
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
data: {
|
||||
journal_entry_id: string
|
||||
voucher_number: number
|
||||
allocations: Array<{ payment_id: string }>
|
||||
total_allocated: number
|
||||
}
|
||||
}>(response)
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.journal_entry_id).toBe('je-batch-1')
|
||||
expect(body.data.voucher_number).toBe(12)
|
||||
expect(body.data.allocations).toHaveLength(1)
|
||||
expect(body.data.total_allocated).toBe(1000)
|
||||
})
|
||||
|
||||
it('maps an RPC structured failure to errorResponseFromCode', async () => {
|
||||
enqueue({
|
||||
data: {
|
||||
ok: false,
|
||||
code: 'BATCH_OVERSHOOT',
|
||||
details: { invoice_id: INV_UUID, requested: 2000, remaining: 1000 },
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
|
||||
const request = createMockRequest(`/api/transactions/${TX_UUID}/match-batch`, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
allocations: [{ kind: 'customer_invoice', invoice_id: INV_UUID, amount: 2000 }],
|
||||
},
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: TX_UUID }))
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
expect(status).toBe(400)
|
||||
expect(body.error.code).toBe('BATCH_OVERSHOOT')
|
||||
})
|
||||
|
||||
it('maps a raw RPC error to BATCH_RPC_FAILED', async () => {
|
||||
enqueue({ data: null, error: { message: 'connection dropped' } })
|
||||
|
||||
const request = createMockRequest(`/api/transactions/${TX_UUID}/match-batch`, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
allocations: [{ kind: 'customer_invoice', invoice_id: INV_UUID, amount: 1000 }],
|
||||
},
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: TX_UUID }))
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
expect(status).toBe(500)
|
||||
expect(body.error.code).toBe('BATCH_RPC_FAILED')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,162 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { MatchBatchSchema } from '@/lib/api/schemas'
|
||||
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import type { Invoice, SupplierInvoice, Transaction } from '@/types'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
interface RpcAllocationResult {
|
||||
kind: 'customer_invoice' | 'supplier_invoice'
|
||||
invoice_id?: string
|
||||
supplier_invoice_id?: string
|
||||
payment_id: string
|
||||
status: 'paid' | 'partially_paid'
|
||||
paid_amount: number
|
||||
remaining_amount: number
|
||||
amount: number
|
||||
}
|
||||
|
||||
interface RpcOk {
|
||||
ok: true
|
||||
journal_entry_id: string
|
||||
voucher_series: string
|
||||
voucher_number: number
|
||||
tx_id: string
|
||||
allocations: RpcAllocationResult[]
|
||||
total_allocated: number
|
||||
leftover: number
|
||||
}
|
||||
|
||||
interface RpcErr {
|
||||
ok: false
|
||||
code: string
|
||||
details?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/transactions/[id]/match-batch
|
||||
*
|
||||
* Allocate one bank transaction across N customer OR N supplier invoices.
|
||||
* Builds a single combined verifikat (samlingsverifikation) and inserts N
|
||||
* payment rows via the match_batch_allocate PL/pgSQL RPC.
|
||||
*
|
||||
* The RPC is the atomicity boundary; this route is a thin wrapper that:
|
||||
* 1. Validates the request body via MatchBatchSchema.
|
||||
* 2. Invokes the RPC.
|
||||
* 3. Maps the structured RPC error (jsonb { ok: false, code }) to an
|
||||
* errorResponseFromCode call.
|
||||
* 4. On success, refetches the per-allocation invoice/supplier_invoice rows
|
||||
* to emit the same per-allocation events the legacy single-tx routes
|
||||
* emit (invoice.match_confirmed, invoice.paid, supplier_invoice.*).
|
||||
* Event emission is best-effort — a failure here does not roll back
|
||||
* the booking; the RPC commit is the source of truth.
|
||||
*/
|
||||
export const POST = withRouteContext(
|
||||
'transaction.match_batch',
|
||||
async (request, ctx, { params }: { params: Promise<{ id: string }> }) => {
|
||||
const { id: transactionId } = await params
|
||||
const { user, supabase, companyId, log, requestId } = ctx
|
||||
|
||||
const validation = await validateBody(request, MatchBatchSchema, {
|
||||
log,
|
||||
operation: 'transaction.match_batch',
|
||||
})
|
||||
if (!validation.success) return validation.response
|
||||
|
||||
const txLog = log.child({ transactionId })
|
||||
|
||||
const { data, error } = await supabase.rpc('match_batch_allocate', {
|
||||
p_tx_id: transactionId,
|
||||
p_allocations: validation.data.allocations,
|
||||
p_user_id: user.id,
|
||||
p_company_id: companyId,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
txLog.error('match_batch_allocate RPC error', error)
|
||||
return errorResponseFromCode('BATCH_RPC_FAILED', txLog, {
|
||||
requestId,
|
||||
details: { message: error.message },
|
||||
})
|
||||
}
|
||||
|
||||
const result = data as RpcOk | RpcErr | null
|
||||
if (!result || !result.ok) {
|
||||
const code = (result as RpcErr | null)?.code ?? 'BATCH_RPC_FAILED'
|
||||
const details = (result as RpcErr | null)?.details
|
||||
return errorResponseFromCode(code, txLog, { requestId, details })
|
||||
}
|
||||
|
||||
// Re-fetch the transaction row for event payloads (the RPC has already
|
||||
// updated it). Lookup is non-critical — events fail open on miss.
|
||||
const { data: tx } = await supabase
|
||||
.from('transactions')
|
||||
.select('*')
|
||||
.eq('id', transactionId)
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
|
||||
// Emit one event per allocation so existing subscribers (reminder
|
||||
// cancellation, automation, processing-history) keep working without a
|
||||
// new event channel. Loop sequentially so a single failure logs cleanly.
|
||||
for (const alloc of result.allocations) {
|
||||
try {
|
||||
if (alloc.kind === 'customer_invoice' && alloc.invoice_id) {
|
||||
const { data: invoice } = await supabase
|
||||
.from('invoices')
|
||||
.select('*')
|
||||
.eq('id', alloc.invoice_id)
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
if (invoice && tx) {
|
||||
await eventBus.emit({
|
||||
type: 'invoice.match_confirmed',
|
||||
payload: {
|
||||
invoice: invoice as Invoice,
|
||||
transaction: tx as Transaction,
|
||||
userId: user.id,
|
||||
companyId,
|
||||
},
|
||||
})
|
||||
}
|
||||
} else if (alloc.kind === 'supplier_invoice' && alloc.supplier_invoice_id) {
|
||||
const { data: supplierInvoice } = await supabase
|
||||
.from('supplier_invoices')
|
||||
.select('*')
|
||||
.eq('id', alloc.supplier_invoice_id)
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
if (supplierInvoice && tx) {
|
||||
await eventBus.emit({
|
||||
type: 'supplier_invoice.match_confirmed',
|
||||
payload: {
|
||||
supplierInvoice: supplierInvoice as SupplierInvoice,
|
||||
transaction: tx as Transaction,
|
||||
userId: user.id,
|
||||
companyId,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
txLog.warn('match_batch event emission failed', err as Error)
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
journal_entry_id: result.journal_entry_id,
|
||||
voucher_series: result.voucher_series,
|
||||
voucher_number: result.voucher_number,
|
||||
allocations: result.allocations,
|
||||
total_allocated: result.total_allocated,
|
||||
leftover: result.leftover,
|
||||
},
|
||||
})
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
@@ -0,0 +1,478 @@
|
||||
'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 { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { formatCurrency, formatDate, cn } from '@/lib/utils'
|
||||
import { Loader2, Search, X, Plus, Check, AlertTriangle } from 'lucide-react'
|
||||
import type { Invoice, Customer, SupplierInvoice, Supplier } from '@/types'
|
||||
import type { TransactionWithInvoice } from './transaction-types'
|
||||
|
||||
interface MatchAllocationDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
transaction: TransactionWithInvoice | null
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Direction-aware allocation candidate. The dialog normalizes customer and
|
||||
* supplier invoices to the same shape so the row renderer + tally math stay
|
||||
* a single code path. The `kind` discriminator drives the underlying API
|
||||
* payload at submit time.
|
||||
*/
|
||||
interface AllocationCandidate {
|
||||
kind: 'customer_invoice' | 'supplier_invoice'
|
||||
id: string
|
||||
label: string
|
||||
counterpartyName: string
|
||||
remaining: number
|
||||
total: number
|
||||
currency: string
|
||||
dueDate: string
|
||||
}
|
||||
|
||||
type AllocationDraft = {
|
||||
candidateId: string
|
||||
amount: string
|
||||
}
|
||||
|
||||
type CustomerInvoiceRow = Invoice & { customer?: Customer | null }
|
||||
type SupplierInvoiceRow = SupplierInvoice & { supplier?: Supplier | null }
|
||||
|
||||
function parseAmount(s: string): number {
|
||||
// Accept Swedish-style decimal comma + thousand spaces. Empty string → 0.
|
||||
const cleaned = s.replace(/\s+/g, '').replace(',', '.')
|
||||
const n = parseFloat(cleaned)
|
||||
return Number.isFinite(n) ? n : 0
|
||||
}
|
||||
|
||||
function round2(n: number): number {
|
||||
return Math.round(n * 100) / 100
|
||||
}
|
||||
|
||||
export default function MatchAllocationDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
transaction,
|
||||
onSuccess,
|
||||
}: MatchAllocationDialogProps) {
|
||||
const { toast } = useToast()
|
||||
const { company } = useCompany()
|
||||
const supabase = useMemo(() => createClient(), [])
|
||||
const t = useTranslations('tx_match_allocation')
|
||||
|
||||
const kind: 'customer_invoice' | 'supplier_invoice' = useMemo(() => {
|
||||
// Strict > 0 (was >= 0): a zero-amount tx would otherwise load customer
|
||||
// candidates and the RPC would reject with BATCH_TX_ZERO_AMOUNT after
|
||||
// the user has already filled in allocations. PR #603 review fix.
|
||||
return transaction && transaction.amount > 0 ? 'customer_invoice' : 'supplier_invoice'
|
||||
}, [transaction])
|
||||
|
||||
const [candidates, setCandidates] = useState<AllocationCandidate[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [search, setSearch] = useState('')
|
||||
const [drafts, setDrafts] = useState<Record<string, AllocationDraft>>({})
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !transaction || !company) return
|
||||
const companyId = company.id
|
||||
let cancelled = false
|
||||
|
||||
async function load() {
|
||||
setLoading(true)
|
||||
try {
|
||||
if (kind === 'customer_invoice') {
|
||||
// Mirror InvoicePicker's filter: only true invoices (no proformas)
|
||||
// in an open state with a positive remaining balance.
|
||||
const { data } = await supabase
|
||||
.from('invoices')
|
||||
.select('*, customer:customers(id, name)')
|
||||
.eq('company_id', companyId)
|
||||
.eq('document_type', 'invoice')
|
||||
.in('status', ['sent', 'overdue', 'partially_paid'])
|
||||
.gt('remaining_amount', 0)
|
||||
.order('due_date', { ascending: true })
|
||||
if (cancelled) return
|
||||
const rows = (data ?? []) as CustomerInvoiceRow[]
|
||||
setCandidates(
|
||||
rows.map((r) => ({
|
||||
kind: 'customer_invoice',
|
||||
id: r.id,
|
||||
label: r.invoice_number ?? r.id.slice(0, 8),
|
||||
counterpartyName: r.customer?.name ?? t('unknown_customer'),
|
||||
remaining: Number(r.remaining_amount ?? r.total ?? 0),
|
||||
total: Number(r.total ?? 0),
|
||||
currency: r.currency,
|
||||
dueDate: r.due_date,
|
||||
})),
|
||||
)
|
||||
} else {
|
||||
const { data } = await supabase
|
||||
.from('supplier_invoices')
|
||||
.select('*, supplier:suppliers(id, name)')
|
||||
.eq('company_id', companyId)
|
||||
.in('status', ['registered', 'approved', 'overdue', 'partially_paid'])
|
||||
.gt('remaining_amount', 0)
|
||||
.order('due_date', { ascending: true })
|
||||
if (cancelled) return
|
||||
const rows = (data ?? []) as SupplierInvoiceRow[]
|
||||
setCandidates(
|
||||
rows.map((r) => ({
|
||||
kind: 'supplier_invoice',
|
||||
id: r.id,
|
||||
label: r.supplier_invoice_number ?? `LF-${r.arrival_number}`,
|
||||
counterpartyName: r.supplier?.name ?? t('unknown_supplier'),
|
||||
remaining: Number(r.remaining_amount ?? r.total ?? 0),
|
||||
total: Number(r.total ?? 0),
|
||||
currency: r.currency,
|
||||
dueDate: r.due_date,
|
||||
})),
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
}
|
||||
load()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [open, transaction, company, kind, supabase, t])
|
||||
|
||||
// Reset state every time the dialog re-opens for a new tx.
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setDrafts({})
|
||||
setSearch('')
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const txAmountAbs = transaction ? Math.abs(transaction.amount) : 0
|
||||
|
||||
const allocated = useMemo(() => {
|
||||
return Object.values(drafts).reduce((sum, d) => sum + parseAmount(d.amount), 0)
|
||||
}, [drafts])
|
||||
|
||||
const leftover = round2(txAmountAbs - allocated)
|
||||
const overshoot = leftover < -0.005
|
||||
const balanced = Math.abs(leftover) < 0.005 && Object.keys(drafts).length > 0
|
||||
|
||||
const filteredCandidates = useMemo(() => {
|
||||
const selectedIds = new Set(Object.keys(drafts))
|
||||
const sorted = [...candidates].sort((a, b) => {
|
||||
const aSel = selectedIds.has(a.id)
|
||||
const bSel = selectedIds.has(b.id)
|
||||
if (aSel !== bSel) return aSel ? -1 : 1
|
||||
return a.dueDate.localeCompare(b.dueDate)
|
||||
})
|
||||
if (!search.trim()) return sorted
|
||||
const needle = search.trim().toLowerCase()
|
||||
return sorted.filter((c) => {
|
||||
const haystack = `${c.label} ${c.counterpartyName}`.toLowerCase()
|
||||
return haystack.includes(needle)
|
||||
})
|
||||
}, [candidates, drafts, search])
|
||||
|
||||
function addAllocation(candidate: AllocationCandidate) {
|
||||
setDrafts((prev) => {
|
||||
if (prev[candidate.id]) return prev
|
||||
const remainingTxBudget = Math.max(0, round2(txAmountAbs - allocated))
|
||||
const defaultAmount = Math.min(candidate.remaining, remainingTxBudget)
|
||||
return {
|
||||
...prev,
|
||||
[candidate.id]: {
|
||||
candidateId: candidate.id,
|
||||
amount: defaultAmount > 0 ? defaultAmount.toFixed(2).replace('.', ',') : '',
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function removeAllocation(candidateId: string) {
|
||||
setDrafts((prev) => {
|
||||
const next = { ...prev }
|
||||
delete next[candidateId]
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function setDraftAmount(candidateId: string, amount: string) {
|
||||
setDrafts((prev) => ({
|
||||
...prev,
|
||||
[candidateId]: { candidateId, amount },
|
||||
}))
|
||||
}
|
||||
|
||||
async function handleConfirm() {
|
||||
if (!transaction) return
|
||||
if (!balanced && !overshoot) {
|
||||
// Allow undershoot — the tx keeps its leftover unallocated. But reject
|
||||
// a no-allocation submit.
|
||||
if (Object.keys(drafts).length === 0) return
|
||||
}
|
||||
if (overshoot) return
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const allocations = Object.values(drafts)
|
||||
.map((d) => {
|
||||
const cand = candidates.find((c) => c.id === d.candidateId)
|
||||
if (!cand) return null
|
||||
const amount = parseAmount(d.amount)
|
||||
if (amount <= 0) return null
|
||||
return cand.kind === 'customer_invoice'
|
||||
? { kind: 'customer_invoice' as const, invoice_id: cand.id, amount }
|
||||
: { kind: 'supplier_invoice' as const, supplier_invoice_id: cand.id, amount }
|
||||
})
|
||||
.filter((a): a is NonNullable<typeof a> => a !== null)
|
||||
|
||||
if (allocations.length === 0) {
|
||||
toast({
|
||||
title: t('error_no_allocations_title'),
|
||||
description: t('error_no_allocations_description'),
|
||||
variant: 'destructive',
|
||||
})
|
||||
setSubmitting(false)
|
||||
return
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/transactions/${transaction.id}/match-batch`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ allocations }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => null)
|
||||
toast({
|
||||
title: t('error_submit_title'),
|
||||
description: getErrorMessage(body, {
|
||||
context: kind === 'customer_invoice' ? 'invoice' : 'supplier_invoice',
|
||||
statusCode: response.status,
|
||||
}),
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
toast({
|
||||
title: t('success_title'),
|
||||
description: t('success_description', { count: allocations.length }),
|
||||
variant: 'success',
|
||||
})
|
||||
onSuccess()
|
||||
onOpenChange(false)
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: t('error_submit_title'),
|
||||
description: getErrorMessage(err, {
|
||||
context: kind === 'customer_invoice' ? 'invoice' : 'supplier_invoice',
|
||||
}),
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!transaction) return null
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[640px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{kind === 'customer_invoice' ? t('description_customer') : t('description_supplier')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Transaction summary */}
|
||||
<div className="rounded-lg border bg-card p-3">
|
||||
<p className="text-xs uppercase tracking-wider text-muted-foreground">
|
||||
{t('transaction_label')}
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-medium">{transaction.description}</p>
|
||||
<div className="mt-1 flex items-center justify-between text-sm">
|
||||
<span className="tabular-nums text-muted-foreground">
|
||||
{formatDate(transaction.date)}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'font-medium tabular-nums',
|
||||
transaction.amount > 0 && 'text-success',
|
||||
)}
|
||||
>
|
||||
{transaction.amount > 0 ? '+' : ''}
|
||||
{formatCurrency(transaction.amount, transaction.currency)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t('search_placeholder')}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Candidate list */}
|
||||
{loading ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-16 w-full" />
|
||||
<Skeleton className="h-16 w-full" />
|
||||
<Skeleton className="h-16 w-full" />
|
||||
</div>
|
||||
) : filteredCandidates.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed bg-muted/30 p-6 text-center">
|
||||
<p className="text-sm font-medium">{t('empty_title')}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{t('empty_description')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-2 max-h-[320px] overflow-y-auto">
|
||||
{filteredCandidates.map((c) => {
|
||||
const draft = drafts[c.id]
|
||||
const isSelected = !!draft
|
||||
return (
|
||||
<li
|
||||
key={c.id}
|
||||
className={cn(
|
||||
'rounded-lg border bg-card p-3 transition-colors',
|
||||
isSelected ? 'border-foreground' : 'border-border',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium tabular-nums">{c.label}</span>
|
||||
{isSelected && (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<Check className="h-3 w-3" />
|
||||
{t('selected_badge')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{c.counterpartyName}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground tabular-nums">
|
||||
{t('remaining_label', {
|
||||
amount: formatCurrency(c.remaining, c.currency),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
{isSelected ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={draft.amount}
|
||||
onChange={(e) => setDraftAmount(c.id, e.target.value)}
|
||||
className="h-9 w-28 font-mono text-right tabular-nums"
|
||||
aria-label={t('amount_input_aria', { label: c.label })}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() => removeAllocation(c.id)}
|
||||
aria-label={t('remove_aria', { label: c.label })}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => addAllocation(c)}
|
||||
>
|
||||
<Plus className="mr-1 h-3.5 w-3.5" />
|
||||
{t('add_button')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{/* Tally */}
|
||||
<div className="space-y-2 border-t pt-3">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">{t('allocated_label')}</span>
|
||||
<span
|
||||
className={cn(
|
||||
'font-mono tabular-nums',
|
||||
overshoot && 'text-destructive',
|
||||
balanced && 'text-success',
|
||||
)}
|
||||
>
|
||||
{formatCurrency(allocated, transaction.currency)} /{' '}
|
||||
{formatCurrency(txAmountAbs, transaction.currency)}
|
||||
</span>
|
||||
</div>
|
||||
{overshoot ? (
|
||||
<div className="flex items-center gap-2 rounded-lg bg-destructive/10 p-3 text-sm text-destructive">
|
||||
<AlertTriangle className="h-4 w-4 flex-shrink-0" />
|
||||
<p>
|
||||
{t('overshoot_warning', {
|
||||
excess: formatCurrency(Math.abs(leftover), transaction.currency),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
) : balanced ? (
|
||||
<div className="flex items-center gap-2 rounded-lg bg-success/10 p-3 text-sm text-success">
|
||||
<Check className="h-4 w-4 flex-shrink-0" />
|
||||
<p>{t('balanced_message')}</p>
|
||||
</div>
|
||||
) : leftover > 0.005 && Object.keys(drafts).length > 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('leftover_note', {
|
||||
amount: formatCurrency(leftover, transaction.currency),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={submitting}>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
disabled={submitting || overshoot || Object.keys(drafts).length === 0}
|
||||
>
|
||||
{submitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{t('confirm')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
FileText,
|
||||
Link2,
|
||||
Loader2,
|
||||
Split,
|
||||
Trash2,
|
||||
} from 'lucide-react'
|
||||
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
|
||||
@@ -47,6 +48,10 @@ interface TransactionInboxCardProps {
|
||||
onOpenMatchDialog: (transaction: TransactionWithInvoice) => void
|
||||
/** Open the manual picker — routes to customer or supplier picker by amount sign. */
|
||||
onOpenMatchInvoicePicker: (transaction: TransactionWithInvoice) => void
|
||||
/** Open the split-payment allocator (1 tx → N invoices) — same direction
|
||||
* detection as the single-pick picker. Optional so legacy callers stay
|
||||
* source-compatible. */
|
||||
onOpenSplitMatch?: (transaction: TransactionWithInvoice) => void
|
||||
onOpenCategoryDialog: (transaction: TransactionWithInvoice) => void
|
||||
onDelete?: (id: string) => void
|
||||
onToggleSelect: (id: string) => void
|
||||
@@ -61,6 +66,7 @@ export default function TransactionInboxCard({
|
||||
isSelected,
|
||||
onOpenMatchDialog,
|
||||
onOpenMatchInvoicePicker,
|
||||
onOpenSplitMatch,
|
||||
onOpenCategoryDialog,
|
||||
onDelete,
|
||||
onToggleSelect,
|
||||
@@ -178,6 +184,10 @@ export default function TransactionInboxCard({
|
||||
? 'Matcha mot kundfaktura'
|
||||
: 'Matcha mot leverantörsfaktura'
|
||||
|
||||
const splitMatchLabel = isIncome
|
||||
? 'Dela inbetalningen på flera fakturor'
|
||||
: 'Dela utbetalningen på flera leverantörsfakturor'
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
layout
|
||||
@@ -257,6 +267,22 @@ export default function TransactionInboxCard({
|
||||
<Link2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{showInvoiceMatchButton && onOpenSplitMatch && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-9 w-9"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onOpenSplitMatch(transaction)
|
||||
}}
|
||||
aria-label={splitMatchLabel}
|
||||
title={splitMatchLabel}
|
||||
disabled={isProcessing || isDisabled}
|
||||
>
|
||||
<Split className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{/* The Paperclip indicator next to the description
|
||||
(TransactionAttachmentIndicator) is the single click
|
||||
target for opening the underlag. We deliberately don't
|
||||
|
||||
@@ -518,6 +518,52 @@ export const LinkSupplierInvoiceToVoucherSchema = z.object({
|
||||
notes: z.string().max(2000).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Allocate one bank transaction across N customer OR N supplier invoices.
|
||||
* Backed by the match_batch_allocate PL/pgSQL RPC, which builds a single
|
||||
* combined verifikat (samlingsverifikation) and inserts N payment rows.
|
||||
*/
|
||||
export const MatchBatchSchema = z
|
||||
.object({
|
||||
allocations: z
|
||||
.array(
|
||||
z.discriminatedUnion('kind', [
|
||||
z.object({
|
||||
kind: z.literal('customer_invoice'),
|
||||
invoice_id: uuid,
|
||||
// Strictly positive — zero or negative is rejected at the schema
|
||||
// layer (PR #603 review) so the RPC's BATCH_INVALID_AMOUNT path
|
||||
// is only reachable from non-HTTP callers.
|
||||
amount: z.number().positive('Allocation amount must be greater than 0'),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal('supplier_invoice'),
|
||||
supplier_invoice_id: uuid,
|
||||
amount: z.number().positive('Allocation amount must be greater than 0'),
|
||||
}),
|
||||
]),
|
||||
)
|
||||
.min(1, 'At least one allocation is required')
|
||||
// Cap at 100 to prevent DoS via unbounded FOR UPDATE locks in the RPC
|
||||
// (PR #603 compliance review — OWASP V4.2). Domain-appropriate ceiling:
|
||||
// a real samlingsverifikat rarely covers more than a few dozen invoices.
|
||||
.max(100, 'At most 100 allocations per batch'),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
// Reject mixed customer + supplier in a single batch — semantically a
|
||||
// single bank transfer settles invoices on one side. The RPC also guards
|
||||
// this with BATCH_MIXED_KINDS_UNSUPPORTED, but rejecting at the schema
|
||||
// layer gives a cleaner 400 with a per-field path.
|
||||
const kinds = new Set(data.allocations.map((a) => a.kind))
|
||||
if (kinds.size > 1) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['allocations'],
|
||||
message: 'Allocations cannot mix customer_invoice and supplier_invoice kinds',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export const LinkTransactionJournalEntrySchema = z.object({
|
||||
journal_entry_id: uuid,
|
||||
// Optional invoice to settle alongside the link. When provided, the
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
getDefaultVatTreatmentForCategory,
|
||||
buildMappingResultFromCategory,
|
||||
} from '../category-mapping'
|
||||
import { BAS_REFERENCE } from '../bas-data'
|
||||
import { makeTransaction } from '@/tests/helpers'
|
||||
import type { TransactionCategory, VatTreatment } from '@/types'
|
||||
|
||||
@@ -64,7 +65,7 @@ describe('getDefaultAccountForCategory', () => {
|
||||
it('returns expense account for expense categories', () => {
|
||||
expect(getDefaultAccountForCategory('expense_equipment')).toBe('5410')
|
||||
expect(getDefaultAccountForCategory('expense_software')).toBe('5420')
|
||||
expect(getDefaultAccountForCategory('expense_travel')).toBe('5800')
|
||||
expect(getDefaultAccountForCategory('expense_travel')).toBe('5890')
|
||||
expect(getDefaultAccountForCategory('expense_office')).toBe('6110')
|
||||
expect(getDefaultAccountForCategory('expense_bank_fees')).toBe('6570')
|
||||
})
|
||||
@@ -72,7 +73,7 @@ describe('getDefaultAccountForCategory', () => {
|
||||
it('returns income account for income categories', () => {
|
||||
expect(getDefaultAccountForCategory('income_services')).toBe('3001')
|
||||
expect(getDefaultAccountForCategory('income_products')).toBe('3001')
|
||||
expect(getDefaultAccountForCategory('income_other')).toBe('3900')
|
||||
expect(getDefaultAccountForCategory('income_other')).toBe('3999')
|
||||
})
|
||||
|
||||
it('returns private account for enskild firma', () => {
|
||||
@@ -232,10 +233,10 @@ describe('income account resolves by VAT treatment', () => {
|
||||
expect(result.creditAccount).toBe(expectedAccount)
|
||||
})
|
||||
|
||||
it('income_other always returns 3900 regardless of VAT treatment', () => {
|
||||
it('income_other always returns 3999 regardless of VAT treatment', () => {
|
||||
for (const vat of ['standard_25', 'reduced_12', 'reduced_6', 'export', 'reverse_charge', 'exempt'] as VatTreatment[]) {
|
||||
const result = getCategoryAccountMapping('income_other', 1000, true, 'enskild_firma', vat)
|
||||
expect(result.creditAccount).toBe('3900')
|
||||
expect(result.creditAccount).toBe('3999')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -270,3 +271,60 @@ describe('private transaction accounts by entity type and direction', () => {
|
||||
expect(getDefaultAccountForCategory('private', 'enskild_firma')).toBe('2013')
|
||||
})
|
||||
})
|
||||
|
||||
describe('category default → leaf account guarantee', () => {
|
||||
// BAS encodes the parent/leaf distinction in account_name via the
|
||||
// "(gruppkonto)" suffix. Auditors and Skatteverket downstream reporting
|
||||
// expect postings on leaves, not headers — see migration 03d4b740.
|
||||
const groupAccountNumbers = new Set<string>()
|
||||
for (const acct of BAS_REFERENCE) {
|
||||
if (acct.account_name.includes('(gruppkonto)')) {
|
||||
groupAccountNumbers.add(acct.account_number)
|
||||
}
|
||||
}
|
||||
|
||||
const categoriesUnderGuard: TransactionCategory[] = [
|
||||
'income_services',
|
||||
'income_products',
|
||||
'income_other',
|
||||
'expense_equipment',
|
||||
'expense_software',
|
||||
'expense_travel',
|
||||
'expense_office',
|
||||
'expense_marketing',
|
||||
'expense_professional_services',
|
||||
'expense_representation',
|
||||
'expense_consumables',
|
||||
'expense_vehicle',
|
||||
'expense_telecom',
|
||||
'expense_education',
|
||||
'expense_bank_fees',
|
||||
'expense_card_fees',
|
||||
'expense_currency_exchange',
|
||||
'expense_other',
|
||||
'private',
|
||||
'uncategorized',
|
||||
]
|
||||
|
||||
it.each(categoriesUnderGuard)('%s default does not resolve to a gruppkonto', (category) => {
|
||||
const target = getDefaultAccountForCategory(category)
|
||||
expect(groupAccountNumbers.has(target)).toBe(false)
|
||||
})
|
||||
|
||||
it('uncategorized positive amount does not credit a gruppkonto', () => {
|
||||
const result = getCategoryAccountMapping('uncategorized', 1000, true)
|
||||
expect(groupAccountNumbers.has(result.creditAccount)).toBe(false)
|
||||
})
|
||||
|
||||
it('expense_telecom resolves to 6230 (Datakommunikation, leaf)', () => {
|
||||
expect(getDefaultAccountForCategory('expense_telecom')).toBe('6230')
|
||||
})
|
||||
|
||||
it('expense_travel resolves to 5890 (Övriga resekostnader, leaf)', () => {
|
||||
expect(getDefaultAccountForCategory('expense_travel')).toBe('5890')
|
||||
})
|
||||
|
||||
it('income_other resolves to 3999 (Övriga rörelseintäkter, leaf)', () => {
|
||||
expect(getDefaultAccountForCategory('income_other')).toBe('3999')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -40,14 +40,14 @@ const PRIVATE_ACCOUNTS: Record<EntityType, string> = {
|
||||
const EXPENSE_ACCOUNTS: Record<string, string> = {
|
||||
expense_equipment: '5410', // Förbrukningsinventarier
|
||||
expense_software: '5420', // Programvaror
|
||||
expense_travel: '5800', // Resekostnader
|
||||
expense_travel: '5890', // Övriga resekostnader (5800 är gruppkonto)
|
||||
expense_office: '6110', // Kontorsförbrukning
|
||||
expense_marketing: '5910', // Annonsering
|
||||
expense_professional_services: '6530', // Redovisningstjänster
|
||||
expense_representation: '6071', // Representation, avdragsgill
|
||||
expense_consumables: '5460', // Förbrukningsvaror
|
||||
expense_vehicle: '5611', // Drivmedel bil
|
||||
expense_telecom: '6200', // Telefon och internet
|
||||
expense_telecom: '6230', // Datakommunikation (6200 är gruppkonto)
|
||||
expense_bank_fees: '6570', // Bankavgifter
|
||||
expense_card_fees: '6570', // Kortavgifter
|
||||
expense_currency_exchange: '7960', // Valutakursförluster
|
||||
@@ -58,7 +58,7 @@ const EXPENSE_ACCOUNTS: Record<string, string> = {
|
||||
const INCOME_ACCOUNTS: Record<string, string> = {
|
||||
income_services: '3001', // Försäljning tjänster 25%
|
||||
income_products: '3001', // Försäljning varor 25% moms
|
||||
income_other: '3900', // Övriga rörelseintäkter
|
||||
income_other: '3999', // Övriga rörelseintäkter (3900 är gruppkonto)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -78,8 +78,8 @@ function getExpenseAccount(category: string, entityType: EntityType = 'enskild_f
|
||||
* 3001=25%, 3002=12%, 3003=6%, 3305=Export, 3308=EU services, 3004=Exempt.
|
||||
*/
|
||||
function getIncomeAccount(category: string, vatTreatment?: VatTreatment): string {
|
||||
// income_other always maps to 3900 regardless of VAT treatment
|
||||
if (category === 'income_other') return '3900'
|
||||
// income_other always maps to 3999 regardless of VAT treatment (3900 är gruppkonto)
|
||||
if (category === 'income_other') return '3999'
|
||||
|
||||
if (vatTreatment) {
|
||||
switch (vatTreatment) {
|
||||
@@ -93,7 +93,7 @@ function getIncomeAccount(category: string, vatTreatment?: VatTreatment): string
|
||||
}
|
||||
|
||||
// No vatTreatment provided — fall back to static mapping
|
||||
return INCOME_ACCOUNTS[category] || '3900'
|
||||
return INCOME_ACCOUNTS[category] || '3999'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -194,7 +194,7 @@ export function getCategoryAccountMapping(
|
||||
} else {
|
||||
return {
|
||||
debitAccount: BANK_ACCOUNT,
|
||||
creditAccount: '3900',
|
||||
creditAccount: '3999',
|
||||
vatTreatment: null,
|
||||
vatDebitAccount: null,
|
||||
vatCreditAccount: null,
|
||||
@@ -326,7 +326,7 @@ export function getDefaultAccountForCategory(
|
||||
}
|
||||
|
||||
if (category.startsWith('income_')) {
|
||||
return INCOME_ACCOUNTS[category] || '3900'
|
||||
return INCOME_ACCOUNTS[category] || '3999'
|
||||
}
|
||||
|
||||
// uncategorized
|
||||
|
||||
@@ -1802,6 +1802,134 @@ const LINK_SI_VOUCHER: Record<string, StructuredErrorEntry> = {
|
||||
},
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// Batch allocation (match_batch_allocate RPC)
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const MATCH_BATCH: Record<string, StructuredErrorEntry> = {
|
||||
BATCH_TX_NOT_FOUND: {
|
||||
httpStatus: 404,
|
||||
message_sv: 'Transaktionen kunde inte hittas.',
|
||||
message_en: 'Transaction not found.',
|
||||
},
|
||||
BATCH_UNAUTHORIZED: {
|
||||
httpStatus: 403,
|
||||
message_sv: 'Du har inte behörighet att fördela transaktioner för det här företaget.',
|
||||
message_en: 'You are not authorized to allocate transactions for this company.',
|
||||
},
|
||||
BATCH_TX_ALREADY_BOOKED: {
|
||||
httpStatus: 409,
|
||||
message_sv:
|
||||
'Transaktionen är redan bokförd. Avbokföra först (storno) innan du fördelar den på flera fakturor.',
|
||||
message_en:
|
||||
'Transaction is already booked. Reverse the existing journal entry before re-allocating.',
|
||||
},
|
||||
BATCH_TX_ZERO_AMOUNT: {
|
||||
httpStatus: 400,
|
||||
message_sv: 'Transaktioner med beloppet 0 kan inte bokföras.',
|
||||
message_en: 'Zero-amount transactions cannot be allocated.',
|
||||
},
|
||||
BATCH_NO_ALLOCATIONS: {
|
||||
httpStatus: 400,
|
||||
message_sv: 'Minst en fördelning krävs.',
|
||||
message_en: 'At least one allocation is required.',
|
||||
},
|
||||
BATCH_INVALID_AMOUNT: {
|
||||
httpStatus: 400,
|
||||
message_sv: 'Fördelningens belopp måste vara positivt.',
|
||||
message_en: 'Allocation amount must be positive.',
|
||||
},
|
||||
BATCH_DUPLICATE_ALLOCATION: {
|
||||
httpStatus: 400,
|
||||
message_sv:
|
||||
'Samma faktura förekommer två gånger i fördelningen. Slå ihop beloppen eller ta bort dubbletten.',
|
||||
message_en:
|
||||
'The same invoice appears twice in the allocations. Merge the amounts or remove the duplicate.',
|
||||
},
|
||||
BATCH_INVALID_KIND: {
|
||||
httpStatus: 400,
|
||||
message_sv:
|
||||
'Okänd typ av fördelning. Endast customer_invoice och supplier_invoice stöds.',
|
||||
message_en:
|
||||
'Unknown allocation kind. Only customer_invoice and supplier_invoice are supported.',
|
||||
},
|
||||
BATCH_INVOICE_NOT_FOUND: {
|
||||
httpStatus: 404,
|
||||
message_sv: 'En av fakturorna i fördelningen kunde inte hittas.',
|
||||
message_en: 'One of the invoices in the allocation could not be found.',
|
||||
},
|
||||
BATCH_INVOICE_NOT_OPEN: {
|
||||
httpStatus: 409,
|
||||
message_sv: 'En av fakturorna är inte i ett obetalt läge och kan inte ta emot betalning.',
|
||||
message_en: 'One of the invoices is not in an open state.',
|
||||
},
|
||||
BATCH_SUPPLIER_INVOICE_NOT_FOUND: {
|
||||
httpStatus: 404,
|
||||
message_sv: 'En av leverantörsfakturorna i fördelningen kunde inte hittas.',
|
||||
message_en: 'One of the supplier invoices in the allocation could not be found.',
|
||||
},
|
||||
BATCH_SUPPLIER_INVOICE_NOT_OPEN: {
|
||||
httpStatus: 409,
|
||||
message_sv:
|
||||
'En av leverantörsfakturorna är inte i ett obetalt läge och kan inte ta emot betalning.',
|
||||
message_en: 'One of the supplier invoices is not in an open state.',
|
||||
},
|
||||
BATCH_OVERSHOOT: {
|
||||
httpStatus: 400,
|
||||
message_sv:
|
||||
'En av fördelningarna överskrider fakturans återstående belopp. Sänk beloppet eller fördela överskottet på fler fakturor.',
|
||||
message_en:
|
||||
'One allocation exceeds the invoice remaining amount. Lower it or split the excess across additional invoices.',
|
||||
},
|
||||
BATCH_AMOUNT_EXCEEDS_TX: {
|
||||
httpStatus: 400,
|
||||
message_sv:
|
||||
'Summan av fördelningarna är större än transaktionens belopp.',
|
||||
message_en: 'Sum of allocations exceeds the transaction amount.',
|
||||
},
|
||||
BATCH_MIXED_KINDS_UNSUPPORTED: {
|
||||
httpStatus: 400,
|
||||
message_sv:
|
||||
'En transaktion kan inte fördelas på både kund- och leverantörsfakturor i samma verifikat. Skapa två separata fördelningar.',
|
||||
message_en:
|
||||
'A single transaction cannot allocate to both customer and supplier invoices in one batch.',
|
||||
},
|
||||
BATCH_DIRECTION_MISMATCH: {
|
||||
httpStatus: 400,
|
||||
message_sv:
|
||||
'Transaktionens riktning matchar inte fördelningens typ. Kundfakturor kräver inkommande, leverantörsfakturor utgående.',
|
||||
message_en:
|
||||
'Transaction direction does not match allocation kind: customer invoices require income, supplier invoices require expense.',
|
||||
},
|
||||
BATCH_CURRENCY_MISMATCH: {
|
||||
httpStatus: 400,
|
||||
message_sv:
|
||||
'Fakturans valuta matchar inte transaktionens. Endast samma valuta stöds i V1.',
|
||||
message_en:
|
||||
'Invoice currency does not match the transaction currency. Same-currency only in v1.',
|
||||
},
|
||||
BATCH_NO_FISCAL_PERIOD: {
|
||||
httpStatus: 400,
|
||||
message_sv:
|
||||
'Det finns ingen öppen räkenskapsperiod för transaktionens datum. Skapa perioden först.',
|
||||
message_en:
|
||||
'No fiscal period exists for the transaction date. Create the period first.',
|
||||
},
|
||||
BATCH_PERIOD_LOCKED: {
|
||||
httpStatus: 409,
|
||||
message_sv:
|
||||
'Räkenskapsperioden för transaktionens datum är stängd. Öppna perioden eller välj ett annat datum.',
|
||||
message_en:
|
||||
'Fiscal period for the transaction date is closed/locked. Open the period or pick a different date.',
|
||||
},
|
||||
BATCH_RPC_FAILED: {
|
||||
httpStatus: 500,
|
||||
message_sv: 'Databasfel under fördelning. Försök igen.',
|
||||
message_en: 'Database error during batch allocation. Please retry.',
|
||||
retryable: true,
|
||||
},
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// Combined registry
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
@@ -1814,6 +1942,7 @@ const REGISTRY: Record<string, StructuredErrorEntry> = {
|
||||
...LINK_TX_JE,
|
||||
...LINK_INVOICE_VOUCHER,
|
||||
...LINK_SI_VOUCHER,
|
||||
...MATCH_BATCH,
|
||||
...MATCH_SI,
|
||||
...INVOICE,
|
||||
...SUPPLIER_INVOICE,
|
||||
|
||||
@@ -1816,6 +1816,33 @@
|
||||
"exact_match": "Exact match",
|
||||
"no_search_results": "No invoice matches \"{term}\""
|
||||
},
|
||||
"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§.",
|
||||
"description_supplier": "Allocate the outgoing payment across one or more supplier invoices. The verifikat lands as a samlingsverifikation per BFL 5 kap 6§.",
|
||||
"transaction_label": "Transaction",
|
||||
"search_placeholder": "Search invoice number, customer or supplier...",
|
||||
"empty_title": "No open invoices",
|
||||
"empty_description": "There are no invoices in an open state to allocate against.",
|
||||
"unknown_customer": "Unknown customer",
|
||||
"unknown_supplier": "Unknown supplier",
|
||||
"remaining_label": "Remaining: {amount}",
|
||||
"selected_badge": "Selected",
|
||||
"add_button": "Add",
|
||||
"amount_input_aria": "Amount for {label}",
|
||||
"remove_aria": "Remove {label}",
|
||||
"allocated_label": "Allocated",
|
||||
"balanced_message": "Amounts match — ready to confirm.",
|
||||
"overshoot_warning": "Allocation exceeds the transaction by {excess}. Lower an amount or remove an invoice.",
|
||||
"leftover_note": "{amount} left to allocate (leave unallocated or add more invoices).",
|
||||
"error_no_allocations_title": "No allocations",
|
||||
"error_no_allocations_description": "Pick at least one invoice to allocate the payment to.",
|
||||
"error_submit_title": "Allocation could not be saved",
|
||||
"success_title": "Payment allocated",
|
||||
"success_description": "{count, plural, one {The invoice has} other {The invoices have}} been booked against the transaction.",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm allocation"
|
||||
},
|
||||
"tx_skattekonto_card": {
|
||||
"skv_badge": "Skatteverket",
|
||||
"duplicate_title_with_voucher": "Possible duplicate of voucher {label}",
|
||||
|
||||
@@ -1816,6 +1816,33 @@
|
||||
"exact_match": "Exakt match",
|
||||
"no_search_results": "Ingen faktura matchar \"{term}\""
|
||||
},
|
||||
"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§.",
|
||||
"description_supplier": "Fördela utbetalningen på en eller flera leverantörsfakturor. Verifikationen skapas som en samlingsverifikation per BFL 5 kap 6§.",
|
||||
"transaction_label": "Transaktion",
|
||||
"search_placeholder": "Sök fakturanummer, kund eller leverantör...",
|
||||
"empty_title": "Inga öppna fakturor",
|
||||
"empty_description": "Det finns inga fakturor i ett obetalt läge att fördela mot.",
|
||||
"unknown_customer": "Okänd kund",
|
||||
"unknown_supplier": "Okänd leverantör",
|
||||
"remaining_label": "Återstår: {amount}",
|
||||
"selected_badge": "Vald",
|
||||
"add_button": "Lägg till",
|
||||
"amount_input_aria": "Belopp för {label}",
|
||||
"remove_aria": "Ta bort {label}",
|
||||
"allocated_label": "Tilldelat",
|
||||
"balanced_message": "Beloppen stämmer — klart att bekräfta.",
|
||||
"overshoot_warning": "Fördelningen överskrider transaktionen med {excess}. Sänk något belopp eller ta bort en faktura.",
|
||||
"leftover_note": "{amount} kvar att fördela (lämna oallokerat eller lägg till fler fakturor).",
|
||||
"error_no_allocations_title": "Inga fördelningar",
|
||||
"error_no_allocations_description": "Välj minst en faktura att fördela betalningen på.",
|
||||
"error_submit_title": "Fördelningen kunde inte sparas",
|
||||
"success_title": "Betalning fördelad",
|
||||
"success_description": "{count, plural, one {Fakturan} other {Fakturorna}} har bokförts mot transaktionen.",
|
||||
"cancel": "Avbryt",
|
||||
"confirm": "Bekräfta fördelning"
|
||||
},
|
||||
"tx_skattekonto_card": {
|
||||
"skv_badge": "Skatteverket",
|
||||
"duplicate_title_with_voucher": "Möjlig dublett av verifikat {label}",
|
||||
|
||||
@@ -0,0 +1,528 @@
|
||||
-- Phase 3a — match_batch_allocate RPC.
|
||||
--
|
||||
-- Atomically allocate one bank transaction across N customer invoices OR N
|
||||
-- supplier invoices (not mixed in v1). Builds ONE combined verifikat per
|
||||
-- Swedish samlingsverifikation convention (BFL 5 kap 6§ st 3): one
|
||||
-- affärshändelse = one verifikat. Inserts N payment rows pointing at the
|
||||
-- single new JE.
|
||||
--
|
||||
-- All target invoice rows are SELECT … FOR UPDATE locked in id order before
|
||||
-- any writes, so concurrent batches can't both succeed against the same
|
||||
-- invoice's remaining_amount.
|
||||
--
|
||||
-- Returns jsonb { ok, journal_entry_id, voucher_series, voucher_number,
|
||||
-- allocations: [...] } on success, or { ok: false, code, details } on any
|
||||
-- guard failure. Returning rather than RAISEing keeps the route mapping
|
||||
-- simple and avoids transaction-rollback ambiguity (we validate everything
|
||||
-- before any write).
|
||||
--
|
||||
-- Existing voucher allocation (linking the bank tx to an already-posted
|
||||
-- verifikat) is deferred to a follow-up — for that path the user takes the
|
||||
-- "Länka till verifikat" action which calls /api/reconciliation/bank/link.
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.match_batch_allocate(
|
||||
p_tx_id uuid,
|
||||
p_allocations 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_abs numeric;
|
||||
v_allocation jsonb;
|
||||
v_alloc_index int := 0;
|
||||
v_kind text;
|
||||
v_invoice_id uuid;
|
||||
v_supplier_invoice_id uuid;
|
||||
v_alloc_amount numeric;
|
||||
v_total_allocated numeric := 0;
|
||||
v_has_customer boolean := false;
|
||||
v_has_supplier boolean := false;
|
||||
|
||||
v_invoice RECORD;
|
||||
v_si_invoice RECORD;
|
||||
v_supplier_name text;
|
||||
v_supplier_invoice_number text;
|
||||
v_invoice_number text;
|
||||
|
||||
v_fiscal_period_id uuid;
|
||||
v_period_is_closed boolean;
|
||||
v_period_locked_at timestamptz;
|
||||
v_journal_entry_id uuid := uuid_generate_v4();
|
||||
v_voucher_series text := 'A';
|
||||
v_voucher_number int;
|
||||
v_entry_description text;
|
||||
v_line_sort_order int := 0;
|
||||
|
||||
v_new_paid numeric;
|
||||
v_new_remaining numeric;
|
||||
v_new_status text;
|
||||
v_now timestamptz := now();
|
||||
|
||||
v_payment_id uuid;
|
||||
v_results jsonb := '[]'::jsonb;
|
||||
BEGIN
|
||||
-- ─────────────────────────────────────────────────────────
|
||||
-- 1. Lock the transaction row + sanity-check it
|
||||
-- ─────────────────────────────────────────────────────────
|
||||
SELECT * INTO v_tx
|
||||
FROM public.transactions
|
||||
WHERE id = p_tx_id
|
||||
AND company_id = p_company_id
|
||||
FOR UPDATE;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_TX_NOT_FOUND');
|
||||
END IF;
|
||||
|
||||
IF v_tx.journal_entry_id IS NOT NULL THEN
|
||||
RETURN jsonb_build_object(
|
||||
'ok', false,
|
||||
'code', 'BATCH_TX_ALREADY_BOOKED',
|
||||
'details', jsonb_build_object('journal_entry_id', v_tx.journal_entry_id)
|
||||
);
|
||||
END IF;
|
||||
|
||||
IF v_tx.amount = 0 THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_TX_ZERO_AMOUNT');
|
||||
END IF;
|
||||
|
||||
v_tx_abs := ABS(v_tx.amount);
|
||||
|
||||
-- ─────────────────────────────────────────────────────────
|
||||
-- 2. Validate allocations array shape
|
||||
-- ─────────────────────────────────────────────────────────
|
||||
IF jsonb_typeof(p_allocations) IS DISTINCT FROM 'array'
|
||||
OR jsonb_array_length(p_allocations) = 0 THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_NO_ALLOCATIONS');
|
||||
END IF;
|
||||
|
||||
-- ─────────────────────────────────────────────────────────
|
||||
-- 3. First pass: validate + lock each allocation target.
|
||||
-- All validation happens before any write so the early-return path
|
||||
-- doesn't leave half-applied state.
|
||||
-- ─────────────────────────────────────────────────────────
|
||||
FOR v_allocation IN SELECT * FROM jsonb_array_elements(p_allocations)
|
||||
LOOP
|
||||
v_kind := v_allocation->>'kind';
|
||||
v_alloc_amount := (v_allocation->>'amount')::numeric;
|
||||
|
||||
IF v_alloc_amount IS NULL OR v_alloc_amount <= 0 THEN
|
||||
RETURN jsonb_build_object(
|
||||
'ok', false,
|
||||
'code', 'BATCH_INVALID_AMOUNT',
|
||||
'details', jsonb_build_object('index', v_alloc_index, 'amount', v_alloc_amount)
|
||||
);
|
||||
END IF;
|
||||
|
||||
v_total_allocated := v_total_allocated + v_alloc_amount;
|
||||
|
||||
IF v_kind = 'customer_invoice' THEN
|
||||
v_has_customer := true;
|
||||
v_invoice_id := (v_allocation->>'invoice_id')::uuid;
|
||||
|
||||
SELECT * INTO v_invoice
|
||||
FROM public.invoices
|
||||
WHERE id = v_invoice_id AND company_id = p_company_id
|
||||
FOR UPDATE;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object(
|
||||
'ok', false,
|
||||
'code', 'BATCH_INVOICE_NOT_FOUND',
|
||||
'details', jsonb_build_object('index', v_alloc_index, 'invoice_id', v_invoice_id)
|
||||
);
|
||||
END IF;
|
||||
|
||||
IF v_invoice.status NOT IN ('sent', 'overdue', 'partially_paid') THEN
|
||||
RETURN jsonb_build_object(
|
||||
'ok', false,
|
||||
'code', 'BATCH_INVOICE_NOT_OPEN',
|
||||
'details', jsonb_build_object(
|
||||
'index', v_alloc_index, 'invoice_id', v_invoice_id, 'status', v_invoice.status
|
||||
)
|
||||
);
|
||||
END IF;
|
||||
|
||||
IF v_alloc_amount > COALESCE(v_invoice.remaining_amount, v_invoice.total) + 0.005 THEN
|
||||
RETURN jsonb_build_object(
|
||||
'ok', false,
|
||||
'code', 'BATCH_OVERSHOOT',
|
||||
'details', jsonb_build_object(
|
||||
'index', v_alloc_index,
|
||||
'invoice_id', v_invoice_id,
|
||||
'requested', v_alloc_amount,
|
||||
'remaining', COALESCE(v_invoice.remaining_amount, v_invoice.total)
|
||||
)
|
||||
);
|
||||
END IF;
|
||||
|
||||
IF v_invoice.currency IS DISTINCT FROM v_tx.currency THEN
|
||||
RETURN jsonb_build_object(
|
||||
'ok', false,
|
||||
'code', 'BATCH_CURRENCY_MISMATCH',
|
||||
'details', jsonb_build_object(
|
||||
'index', v_alloc_index,
|
||||
'invoice_id', v_invoice_id,
|
||||
'invoice_currency', v_invoice.currency,
|
||||
'tx_currency', v_tx.currency
|
||||
)
|
||||
);
|
||||
END IF;
|
||||
|
||||
ELSIF v_kind = 'supplier_invoice' THEN
|
||||
v_has_supplier := true;
|
||||
v_supplier_invoice_id := (v_allocation->>'supplier_invoice_id')::uuid;
|
||||
|
||||
SELECT * INTO v_si_invoice
|
||||
FROM public.supplier_invoices
|
||||
WHERE id = v_supplier_invoice_id AND company_id = p_company_id
|
||||
FOR UPDATE;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object(
|
||||
'ok', false,
|
||||
'code', 'BATCH_SUPPLIER_INVOICE_NOT_FOUND',
|
||||
'details', jsonb_build_object(
|
||||
'index', v_alloc_index, 'supplier_invoice_id', v_supplier_invoice_id
|
||||
)
|
||||
);
|
||||
END IF;
|
||||
|
||||
IF v_si_invoice.status NOT IN ('registered', 'approved', 'overdue', 'partially_paid') THEN
|
||||
RETURN jsonb_build_object(
|
||||
'ok', false,
|
||||
'code', 'BATCH_SUPPLIER_INVOICE_NOT_OPEN',
|
||||
'details', jsonb_build_object(
|
||||
'index', v_alloc_index,
|
||||
'supplier_invoice_id', v_supplier_invoice_id,
|
||||
'status', v_si_invoice.status
|
||||
)
|
||||
);
|
||||
END IF;
|
||||
|
||||
IF v_alloc_amount > COALESCE(v_si_invoice.remaining_amount, v_si_invoice.total) + 0.005 THEN
|
||||
RETURN jsonb_build_object(
|
||||
'ok', false,
|
||||
'code', 'BATCH_OVERSHOOT',
|
||||
'details', jsonb_build_object(
|
||||
'index', v_alloc_index,
|
||||
'supplier_invoice_id', v_supplier_invoice_id,
|
||||
'requested', v_alloc_amount,
|
||||
'remaining', COALESCE(v_si_invoice.remaining_amount, v_si_invoice.total)
|
||||
)
|
||||
);
|
||||
END IF;
|
||||
|
||||
IF v_si_invoice.currency IS DISTINCT FROM v_tx.currency THEN
|
||||
RETURN jsonb_build_object(
|
||||
'ok', false,
|
||||
'code', 'BATCH_CURRENCY_MISMATCH',
|
||||
'details', jsonb_build_object(
|
||||
'index', v_alloc_index,
|
||||
'supplier_invoice_id', v_supplier_invoice_id,
|
||||
'invoice_currency', v_si_invoice.currency,
|
||||
'tx_currency', v_tx.currency
|
||||
)
|
||||
);
|
||||
END IF;
|
||||
|
||||
ELSE
|
||||
RETURN jsonb_build_object(
|
||||
'ok', false,
|
||||
'code', 'BATCH_INVALID_KIND',
|
||||
'details', jsonb_build_object('index', v_alloc_index, 'kind', v_kind)
|
||||
);
|
||||
END IF;
|
||||
|
||||
v_alloc_index := v_alloc_index + 1;
|
||||
END LOOP;
|
||||
|
||||
-- ─────────────────────────────────────────────────────────
|
||||
-- 4. Cross-allocation rules
|
||||
-- ─────────────────────────────────────────────────────────
|
||||
|
||||
IF v_has_customer AND v_has_supplier THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_MIXED_KINDS_UNSUPPORTED');
|
||||
END IF;
|
||||
|
||||
IF v_total_allocated > v_tx_abs + 0.01 THEN
|
||||
RETURN jsonb_build_object(
|
||||
'ok', false,
|
||||
'code', 'BATCH_AMOUNT_EXCEEDS_TX',
|
||||
'details', jsonb_build_object('allocated', v_total_allocated, 'tx_amount_abs', v_tx_abs)
|
||||
);
|
||||
END IF;
|
||||
|
||||
IF v_has_customer AND v_tx.amount <= 0 THEN
|
||||
RETURN jsonb_build_object(
|
||||
'ok', false,
|
||||
'code', 'BATCH_DIRECTION_MISMATCH',
|
||||
'details', jsonb_build_object('expected', 'income', 'tx_amount', v_tx.amount)
|
||||
);
|
||||
END IF;
|
||||
IF v_has_supplier AND v_tx.amount >= 0 THEN
|
||||
RETURN jsonb_build_object(
|
||||
'ok', false,
|
||||
'code', 'BATCH_DIRECTION_MISMATCH',
|
||||
'details', jsonb_build_object('expected', 'expense', 'tx_amount', v_tx.amount)
|
||||
);
|
||||
END IF;
|
||||
|
||||
-- ─────────────────────────────────────────────────────────
|
||||
-- 5. Resolve fiscal period for tx.date and verify it accepts writes.
|
||||
-- The enforce_period_lock trigger would catch a locked period at the
|
||||
-- journal_entries INSERT, but pre-checking gives a cleaner error code
|
||||
-- and avoids partial work in pathological cases.
|
||||
-- ─────────────────────────────────────────────────────────
|
||||
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
|
||||
LIMIT 1;
|
||||
|
||||
IF v_fiscal_period_id IS NULL THEN
|
||||
RETURN jsonb_build_object(
|
||||
'ok', false,
|
||||
'code', 'BATCH_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', 'BATCH_PERIOD_LOCKED',
|
||||
'details', jsonb_build_object(
|
||||
'fiscal_period_id', v_fiscal_period_id,
|
||||
'is_closed', v_period_is_closed,
|
||||
'locked_at', v_period_locked_at
|
||||
)
|
||||
);
|
||||
END IF;
|
||||
|
||||
-- ─────────────────────────────────────────────────────────
|
||||
-- 6. All validations passed. Build the combined verifikat.
|
||||
-- ─────────────────────────────────────────────────────────
|
||||
|
||||
v_entry_description := CASE
|
||||
WHEN v_has_customer THEN 'Samlingsinbetalning ' || v_tx.date
|
||||
ELSE 'Samlingsbetalning ' || v_tx.date
|
||||
END;
|
||||
|
||||
-- Insert draft entry with placeholder voucher_number=0; commit_journal_entry
|
||||
-- will overwrite on commit. Source type 'invoice_paid' is the closest match
|
||||
-- in the source_type CHECK enum for AR/AP payments.
|
||||
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, 'invoice_paid', 'draft'
|
||||
);
|
||||
|
||||
-- Insert per-invoice lines.
|
||||
v_alloc_index := 0;
|
||||
FOR v_allocation IN SELECT * FROM jsonb_array_elements(p_allocations)
|
||||
LOOP
|
||||
v_alloc_amount := (v_allocation->>'amount')::numeric;
|
||||
|
||||
IF v_has_customer THEN
|
||||
v_invoice_id := (v_allocation->>'invoice_id')::uuid;
|
||||
SELECT invoice_number INTO v_invoice_number
|
||||
FROM public.invoices WHERE id = v_invoice_id;
|
||||
|
||||
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, '1510', 0, v_alloc_amount, v_tx.currency,
|
||||
v_line_sort_order, 'Faktura ' || COALESCE(v_invoice_number, '')
|
||||
);
|
||||
ELSE
|
||||
v_supplier_invoice_id := (v_allocation->>'supplier_invoice_id')::uuid;
|
||||
SELECT si.supplier_invoice_number, s.name
|
||||
INTO v_supplier_invoice_number, v_supplier_name
|
||||
FROM public.supplier_invoices si
|
||||
LEFT JOIN public.suppliers s ON s.id = si.supplier_id
|
||||
WHERE si.id = v_supplier_invoice_id;
|
||||
|
||||
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, '2440', v_alloc_amount, 0, v_tx.currency,
|
||||
v_line_sort_order,
|
||||
TRIM(BOTH ' – ' FROM
|
||||
COALESCE(v_supplier_name, '') || ' – ' || COALESCE(v_supplier_invoice_number, '')
|
||||
)
|
||||
);
|
||||
END IF;
|
||||
|
||||
v_line_sort_order := v_line_sort_order + 1;
|
||||
v_alloc_index := v_alloc_index + 1;
|
||||
END LOOP;
|
||||
|
||||
-- Bank settlement line on 1930.
|
||||
IF v_has_customer THEN
|
||||
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, '1930', v_total_allocated, 0, v_tx.currency,
|
||||
v_line_sort_order, 'Inbetalning ' || v_tx.date
|
||||
);
|
||||
ELSE
|
||||
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, '1930', 0, v_total_allocated, v_tx.currency,
|
||||
v_line_sort_order, 'Utbetalning ' || v_tx.date
|
||||
);
|
||||
END IF;
|
||||
|
||||
-- Commit the entry — atomically assigns voucher_number + flips to posted.
|
||||
SELECT voucher_number INTO v_voucher_number
|
||||
FROM public.commit_journal_entry(p_company_id, v_journal_entry_id);
|
||||
|
||||
-- ─────────────────────────────────────────────────────────
|
||||
-- 7. Advance each invoice + insert payment rows.
|
||||
-- ─────────────────────────────────────────────────────────
|
||||
|
||||
v_alloc_index := 0;
|
||||
FOR v_allocation IN SELECT * FROM jsonb_array_elements(p_allocations)
|
||||
LOOP
|
||||
v_alloc_amount := (v_allocation->>'amount')::numeric;
|
||||
|
||||
IF v_has_customer THEN
|
||||
v_invoice_id := (v_allocation->>'invoice_id')::uuid;
|
||||
SELECT * INTO v_invoice FROM public.invoices WHERE id = v_invoice_id;
|
||||
|
||||
v_new_paid := ROUND((COALESCE(v_invoice.paid_amount, 0) + v_alloc_amount) * 100) / 100;
|
||||
v_new_remaining := GREATEST(0,
|
||||
ROUND((COALESCE(v_invoice.remaining_amount, v_invoice.total) - v_alloc_amount) * 100) / 100
|
||||
);
|
||||
v_new_status := CASE WHEN v_new_remaining <= 0.005 THEN 'paid' ELSE 'partially_paid' END;
|
||||
|
||||
UPDATE public.invoices
|
||||
SET status = v_new_status,
|
||||
paid_at = CASE WHEN v_new_status = 'paid' THEN v_now ELSE paid_at END,
|
||||
paid_amount = v_new_paid,
|
||||
remaining_amount = v_new_remaining,
|
||||
updated_at = v_now
|
||||
WHERE id = v_invoice_id;
|
||||
|
||||
INSERT INTO public.invoice_payments (
|
||||
user_id, company_id, invoice_id, payment_date, amount, currency,
|
||||
exchange_rate, journal_entry_id, transaction_id
|
||||
) VALUES (
|
||||
p_user_id, p_company_id, v_invoice_id, v_tx.date, v_alloc_amount, v_invoice.currency,
|
||||
v_invoice.exchange_rate, v_journal_entry_id, p_tx_id
|
||||
)
|
||||
RETURNING id INTO v_payment_id;
|
||||
|
||||
v_results := v_results || jsonb_build_array(jsonb_build_object(
|
||||
'kind', 'customer_invoice',
|
||||
'invoice_id', v_invoice_id,
|
||||
'payment_id', v_payment_id,
|
||||
'status', v_new_status,
|
||||
'paid_amount', v_new_paid,
|
||||
'remaining_amount', v_new_remaining,
|
||||
'amount', v_alloc_amount
|
||||
));
|
||||
|
||||
ELSE
|
||||
v_supplier_invoice_id := (v_allocation->>'supplier_invoice_id')::uuid;
|
||||
SELECT * INTO v_si_invoice FROM public.supplier_invoices WHERE id = v_supplier_invoice_id;
|
||||
|
||||
v_new_paid := ROUND((COALESCE(v_si_invoice.paid_amount, 0) + v_alloc_amount) * 100) / 100;
|
||||
v_new_remaining := GREATEST(0,
|
||||
ROUND((COALESCE(v_si_invoice.remaining_amount, v_si_invoice.total) - v_alloc_amount) * 100) / 100
|
||||
);
|
||||
v_new_status := CASE WHEN v_new_remaining <= 0.005 THEN 'paid' ELSE 'partially_paid' END;
|
||||
|
||||
UPDATE public.supplier_invoices
|
||||
SET status = v_new_status,
|
||||
paid_at = CASE WHEN v_new_status = 'paid' THEN v_now ELSE paid_at END,
|
||||
paid_amount = v_new_paid,
|
||||
remaining_amount = v_new_remaining,
|
||||
payment_journal_entry_id = v_journal_entry_id,
|
||||
updated_at = v_now
|
||||
WHERE id = v_supplier_invoice_id;
|
||||
|
||||
INSERT INTO public.supplier_invoice_payments (
|
||||
user_id, company_id, supplier_invoice_id, payment_date, amount, currency,
|
||||
journal_entry_id, transaction_id
|
||||
) VALUES (
|
||||
p_user_id, p_company_id, v_supplier_invoice_id, v_tx.date, v_alloc_amount,
|
||||
v_si_invoice.currency, v_journal_entry_id, p_tx_id
|
||||
)
|
||||
RETURNING id INTO v_payment_id;
|
||||
|
||||
v_results := v_results || jsonb_build_array(jsonb_build_object(
|
||||
'kind', 'supplier_invoice',
|
||||
'supplier_invoice_id', v_supplier_invoice_id,
|
||||
'payment_id', v_payment_id,
|
||||
'status', v_new_status,
|
||||
'paid_amount', v_new_paid,
|
||||
'remaining_amount', v_new_remaining,
|
||||
'amount', v_alloc_amount
|
||||
));
|
||||
END IF;
|
||||
|
||||
v_alloc_index := v_alloc_index + 1;
|
||||
END LOOP;
|
||||
|
||||
-- ─────────────────────────────────────────────────────────
|
||||
-- 8. Update the transaction. For exactly-one allocation matching the full
|
||||
-- tx amount, set the matching denorm column (preserves 1:1 reader path).
|
||||
-- For multi or partial: leave denorms NULL — is_transaction_booked
|
||||
-- handles via payment rows.
|
||||
-- ─────────────────────────────────────────────────────────
|
||||
UPDATE public.transactions
|
||||
SET journal_entry_id = v_journal_entry_id,
|
||||
is_business = TRUE,
|
||||
invoice_id = CASE
|
||||
WHEN jsonb_array_length(p_allocations) = 1
|
||||
AND v_has_customer
|
||||
AND ABS(v_total_allocated - v_tx_abs) < 0.005
|
||||
THEN (p_allocations->0->>'invoice_id')::uuid
|
||||
ELSE NULL
|
||||
END,
|
||||
supplier_invoice_id = CASE
|
||||
WHEN jsonb_array_length(p_allocations) = 1
|
||||
AND v_has_supplier
|
||||
AND ABS(v_total_allocated - v_tx_abs) < 0.005
|
||||
THEN (p_allocations->0->>'supplier_invoice_id')::uuid
|
||||
ELSE NULL
|
||||
END,
|
||||
potential_invoice_id = NULL,
|
||||
potential_supplier_invoice_id = NULL,
|
||||
category = CASE WHEN v_has_customer THEN 'income_services' ELSE category END,
|
||||
updated_at = v_now
|
||||
WHERE id = p_tx_id;
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'ok', true,
|
||||
'journal_entry_id', v_journal_entry_id,
|
||||
'voucher_series', v_voucher_series,
|
||||
'voucher_number', v_voucher_number,
|
||||
'tx_id', p_tx_id,
|
||||
'allocations', v_results,
|
||||
'total_allocated', v_total_allocated,
|
||||
'leftover', ROUND((v_tx_abs - v_total_allocated) * 100) / 100
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION public.match_batch_allocate(uuid, jsonb, uuid, uuid) IS
|
||||
'Atomically allocate one bank transaction across N customer or N supplier invoices. Builds a single combined verifikat (samlingsverifikation), inserts N payment rows, and advances per-invoice paid/remaining/status. Returns jsonb { ok, ..., allocations } on success or { ok: false, code, details } on guard failure. Mixed customer+supplier kinds are not supported in v1.';
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,350 @@
|
||||
-- PR #603 review fixes for match_batch_allocate (round 1):
|
||||
--
|
||||
-- 1. (P1, greptile) Deadlock-stable locking: previously the FOR UPDATE
|
||||
-- loop ran in caller-supplied array order. Two concurrent calls with
|
||||
-- overlapping invoice sets in opposite array orders would deadlock and
|
||||
-- Postgres' detector would abort one with BATCH_RPC_FAILED. Now we sort
|
||||
-- p_allocations by target id before locking.
|
||||
--
|
||||
-- 2. (P1, greptile) Duplicate-allocation detection: previously a caller
|
||||
-- could include the same invoice_id twice; both iterations saw the
|
||||
-- original remaining_amount, passed the overshoot guard, and the write
|
||||
-- loop inserted two payment rows for the same invoice. Now we track
|
||||
-- seen target ids in the validation loop and reject with a new
|
||||
-- BATCH_DUPLICATE_ALLOCATION code on collision.
|
||||
--
|
||||
-- 3. (CI pg-real) uuid_generate_v4() isn't available in the CI Postgres
|
||||
-- image (uuid-ossp extension off). Switching to gen_random_uuid()
|
||||
-- (built into pgcrypto / Postgres 13+) which is already used across
|
||||
-- the rest of the migrations.
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.match_batch_allocate(
|
||||
p_tx_id uuid,
|
||||
p_allocations 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_abs numeric;
|
||||
v_allocation jsonb;
|
||||
v_alloc_index int := 0;
|
||||
v_kind text;
|
||||
v_invoice_id uuid;
|
||||
v_supplier_invoice_id uuid;
|
||||
v_alloc_amount numeric;
|
||||
v_total_allocated numeric := 0;
|
||||
v_has_customer boolean := false;
|
||||
v_has_supplier boolean := false;
|
||||
v_seen_ids text[] := ARRAY[]::text[];
|
||||
v_target_id text;
|
||||
v_invoice RECORD;
|
||||
v_si_invoice RECORD;
|
||||
v_supplier_name text;
|
||||
v_supplier_invoice_number text;
|
||||
v_invoice_number text;
|
||||
v_fiscal_period_id uuid;
|
||||
v_period_is_closed boolean;
|
||||
v_period_locked_at timestamptz;
|
||||
-- gen_random_uuid is the codebase standard (used by supplier_invoices,
|
||||
-- invoice_inbox, etc.) and is available in CI's bare Postgres image,
|
||||
-- unlike uuid_generate_v4 which depends on the uuid-ossp extension.
|
||||
v_journal_entry_id uuid := gen_random_uuid();
|
||||
v_voucher_series text := 'A';
|
||||
v_voucher_number int;
|
||||
v_entry_description text;
|
||||
v_line_sort_order int := 0;
|
||||
v_new_paid numeric;
|
||||
v_new_remaining numeric;
|
||||
v_new_status text;
|
||||
v_now timestamptz := now();
|
||||
v_payment_id uuid;
|
||||
v_results jsonb := '[]'::jsonb;
|
||||
BEGIN
|
||||
SELECT * INTO v_tx FROM public.transactions
|
||||
WHERE id = p_tx_id AND company_id = p_company_id FOR UPDATE;
|
||||
IF NOT FOUND THEN RETURN jsonb_build_object('ok', false, 'code', 'BATCH_TX_NOT_FOUND'); END IF;
|
||||
|
||||
IF v_tx.journal_entry_id IS NOT NULL THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_TX_ALREADY_BOOKED',
|
||||
'details', jsonb_build_object('journal_entry_id', v_tx.journal_entry_id));
|
||||
END IF;
|
||||
|
||||
IF v_tx.amount = 0 THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_TX_ZERO_AMOUNT');
|
||||
END IF;
|
||||
|
||||
v_tx_abs := ABS(v_tx.amount);
|
||||
|
||||
IF jsonb_typeof(p_allocations) IS DISTINCT FROM 'array'
|
||||
OR jsonb_array_length(p_allocations) = 0 THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_NO_ALLOCATIONS');
|
||||
END IF;
|
||||
|
||||
-- ── Validation pass — locks targets in deadlock-stable order ────────────
|
||||
-- Sort by the target id BEFORE acquiring any FOR UPDATE locks. Two
|
||||
-- concurrent callers with the same target set will now agree on the
|
||||
-- lock order regardless of how they ordered the JSON array, preventing
|
||||
-- the "abc vs cba" deadlock pattern Greptile flagged.
|
||||
FOR v_allocation IN
|
||||
SELECT value FROM jsonb_array_elements(p_allocations) AS t(value)
|
||||
ORDER BY COALESCE(value->>'invoice_id', value->>'supplier_invoice_id', '')
|
||||
LOOP
|
||||
v_kind := v_allocation->>'kind';
|
||||
v_alloc_amount := (v_allocation->>'amount')::numeric;
|
||||
v_target_id := COALESCE(v_allocation->>'invoice_id', v_allocation->>'supplier_invoice_id');
|
||||
|
||||
IF v_alloc_amount IS NULL OR v_alloc_amount <= 0 THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_INVALID_AMOUNT',
|
||||
'details', jsonb_build_object('index', v_alloc_index, 'amount', v_alloc_amount));
|
||||
END IF;
|
||||
|
||||
-- Reject same target in two different allocations of the same batch
|
||||
-- (e.g. invoice_id X listed twice). Without this both iterations would
|
||||
-- pass the per-row overshoot check and the write loop would insert
|
||||
-- two payment rows for the same invoice.
|
||||
IF v_target_id IS NOT NULL AND v_target_id = ANY(v_seen_ids) THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_DUPLICATE_ALLOCATION',
|
||||
'details', jsonb_build_object('id', v_target_id, 'index', v_alloc_index));
|
||||
END IF;
|
||||
IF v_target_id IS NOT NULL THEN
|
||||
v_seen_ids := array_append(v_seen_ids, v_target_id);
|
||||
END IF;
|
||||
|
||||
v_total_allocated := v_total_allocated + v_alloc_amount;
|
||||
|
||||
IF v_kind = 'customer_invoice' THEN
|
||||
v_has_customer := true;
|
||||
v_invoice_id := (v_allocation->>'invoice_id')::uuid;
|
||||
|
||||
SELECT * INTO v_invoice FROM public.invoices
|
||||
WHERE id = v_invoice_id AND company_id = p_company_id FOR UPDATE;
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_INVOICE_NOT_FOUND',
|
||||
'details', jsonb_build_object('index', v_alloc_index, 'invoice_id', v_invoice_id));
|
||||
END IF;
|
||||
|
||||
IF v_invoice.status NOT IN ('sent', 'overdue', 'partially_paid') THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_INVOICE_NOT_OPEN',
|
||||
'details', jsonb_build_object('index', v_alloc_index, 'invoice_id', v_invoice_id, 'status', v_invoice.status));
|
||||
END IF;
|
||||
|
||||
IF v_alloc_amount > COALESCE(v_invoice.remaining_amount, v_invoice.total) + 0.005 THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_OVERSHOOT',
|
||||
'details', jsonb_build_object('index', v_alloc_index, 'invoice_id', v_invoice_id,
|
||||
'requested', v_alloc_amount, 'remaining', COALESCE(v_invoice.remaining_amount, v_invoice.total)));
|
||||
END IF;
|
||||
|
||||
IF v_invoice.currency IS DISTINCT FROM v_tx.currency THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_CURRENCY_MISMATCH',
|
||||
'details', jsonb_build_object('index', v_alloc_index, 'invoice_id', v_invoice_id,
|
||||
'invoice_currency', v_invoice.currency, 'tx_currency', v_tx.currency));
|
||||
END IF;
|
||||
|
||||
ELSIF v_kind = 'supplier_invoice' THEN
|
||||
v_has_supplier := true;
|
||||
v_supplier_invoice_id := (v_allocation->>'supplier_invoice_id')::uuid;
|
||||
|
||||
SELECT * INTO v_si_invoice FROM public.supplier_invoices
|
||||
WHERE id = v_supplier_invoice_id AND company_id = p_company_id FOR UPDATE;
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_SUPPLIER_INVOICE_NOT_FOUND',
|
||||
'details', jsonb_build_object('index', v_alloc_index, 'supplier_invoice_id', v_supplier_invoice_id));
|
||||
END IF;
|
||||
|
||||
IF v_si_invoice.status NOT IN ('registered', 'approved', 'overdue', 'partially_paid') THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_SUPPLIER_INVOICE_NOT_OPEN',
|
||||
'details', jsonb_build_object('index', v_alloc_index, 'supplier_invoice_id', v_supplier_invoice_id, 'status', v_si_invoice.status));
|
||||
END IF;
|
||||
|
||||
IF v_alloc_amount > COALESCE(v_si_invoice.remaining_amount, v_si_invoice.total) + 0.005 THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_OVERSHOOT',
|
||||
'details', jsonb_build_object('index', v_alloc_index, 'supplier_invoice_id', v_supplier_invoice_id,
|
||||
'requested', v_alloc_amount, 'remaining', COALESCE(v_si_invoice.remaining_amount, v_si_invoice.total)));
|
||||
END IF;
|
||||
|
||||
IF v_si_invoice.currency IS DISTINCT FROM v_tx.currency THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_CURRENCY_MISMATCH',
|
||||
'details', jsonb_build_object('index', v_alloc_index, 'supplier_invoice_id', v_supplier_invoice_id,
|
||||
'invoice_currency', v_si_invoice.currency, 'tx_currency', v_tx.currency));
|
||||
END IF;
|
||||
|
||||
ELSE
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_INVALID_KIND',
|
||||
'details', jsonb_build_object('index', v_alloc_index, 'kind', v_kind));
|
||||
END IF;
|
||||
|
||||
v_alloc_index := v_alloc_index + 1;
|
||||
END LOOP;
|
||||
|
||||
IF v_has_customer AND v_has_supplier THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_MIXED_KINDS_UNSUPPORTED');
|
||||
END IF;
|
||||
|
||||
IF v_total_allocated > v_tx_abs + 0.01 THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_AMOUNT_EXCEEDS_TX',
|
||||
'details', jsonb_build_object('allocated', v_total_allocated, 'tx_amount_abs', v_tx_abs));
|
||||
END IF;
|
||||
|
||||
IF v_has_customer AND v_tx.amount <= 0 THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_DIRECTION_MISMATCH',
|
||||
'details', jsonb_build_object('expected', 'income', 'tx_amount', v_tx.amount));
|
||||
END IF;
|
||||
IF v_has_supplier AND v_tx.amount >= 0 THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_DIRECTION_MISMATCH',
|
||||
'details', jsonb_build_object('expected', 'expense', 'tx_amount', v_tx.amount));
|
||||
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 LIMIT 1;
|
||||
|
||||
IF v_fiscal_period_id IS NULL THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_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', 'BATCH_PERIOD_LOCKED',
|
||||
'details', jsonb_build_object('fiscal_period_id', v_fiscal_period_id,
|
||||
'is_closed', v_period_is_closed, 'locked_at', v_period_locked_at));
|
||||
END IF;
|
||||
|
||||
v_entry_description := CASE WHEN v_has_customer THEN 'Samlingsinbetalning ' || v_tx.date ELSE 'Samlingsbetalning ' || v_tx.date END;
|
||||
|
||||
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, 'invoice_paid', 'draft');
|
||||
|
||||
-- Build per-invoice lines in the same sorted order so the verifikat
|
||||
-- line ordering is also caller-stable.
|
||||
v_alloc_index := 0;
|
||||
FOR v_allocation IN
|
||||
SELECT value FROM jsonb_array_elements(p_allocations) AS t(value)
|
||||
ORDER BY COALESCE(value->>'invoice_id', value->>'supplier_invoice_id', '')
|
||||
LOOP
|
||||
v_alloc_amount := (v_allocation->>'amount')::numeric;
|
||||
|
||||
IF v_has_customer THEN
|
||||
v_invoice_id := (v_allocation->>'invoice_id')::uuid;
|
||||
SELECT invoice_number INTO v_invoice_number FROM public.invoices WHERE id = v_invoice_id;
|
||||
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, '1510', 0, v_alloc_amount, v_tx.currency, v_line_sort_order,
|
||||
'Faktura ' || COALESCE(v_invoice_number, ''));
|
||||
ELSE
|
||||
v_supplier_invoice_id := (v_allocation->>'supplier_invoice_id')::uuid;
|
||||
SELECT si.supplier_invoice_number, s.name
|
||||
INTO v_supplier_invoice_number, v_supplier_name
|
||||
FROM public.supplier_invoices si LEFT JOIN public.suppliers s ON s.id = si.supplier_id
|
||||
WHERE si.id = v_supplier_invoice_id;
|
||||
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, '2440', v_alloc_amount, 0, v_tx.currency, v_line_sort_order,
|
||||
TRIM(BOTH ' - ' FROM COALESCE(v_supplier_name, '') || ' - ' || COALESCE(v_supplier_invoice_number, '')));
|
||||
END IF;
|
||||
|
||||
v_line_sort_order := v_line_sort_order + 1;
|
||||
v_alloc_index := v_alloc_index + 1;
|
||||
END LOOP;
|
||||
|
||||
IF v_has_customer THEN
|
||||
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, '1930', v_total_allocated, 0, v_tx.currency, v_line_sort_order,
|
||||
'Inbetalning ' || v_tx.date);
|
||||
ELSE
|
||||
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, '1930', 0, v_total_allocated, v_tx.currency, v_line_sort_order,
|
||||
'Utbetalning ' || v_tx.date);
|
||||
END IF;
|
||||
|
||||
SELECT voucher_number INTO v_voucher_number FROM public.commit_journal_entry(p_company_id, v_journal_entry_id);
|
||||
|
||||
v_alloc_index := 0;
|
||||
FOR v_allocation IN
|
||||
SELECT value FROM jsonb_array_elements(p_allocations) AS t(value)
|
||||
ORDER BY COALESCE(value->>'invoice_id', value->>'supplier_invoice_id', '')
|
||||
LOOP
|
||||
v_alloc_amount := (v_allocation->>'amount')::numeric;
|
||||
|
||||
IF v_has_customer THEN
|
||||
v_invoice_id := (v_allocation->>'invoice_id')::uuid;
|
||||
SELECT * INTO v_invoice FROM public.invoices WHERE id = v_invoice_id;
|
||||
v_new_paid := ROUND((COALESCE(v_invoice.paid_amount, 0) + v_alloc_amount) * 100) / 100;
|
||||
v_new_remaining := GREATEST(0,
|
||||
ROUND((COALESCE(v_invoice.remaining_amount, v_invoice.total) - v_alloc_amount) * 100) / 100);
|
||||
v_new_status := CASE WHEN v_new_remaining <= 0.005 THEN 'paid' ELSE 'partially_paid' END;
|
||||
UPDATE public.invoices SET status = v_new_status,
|
||||
paid_at = CASE WHEN v_new_status = 'paid' THEN v_now ELSE paid_at END,
|
||||
paid_amount = v_new_paid, remaining_amount = v_new_remaining, updated_at = v_now
|
||||
WHERE id = v_invoice_id;
|
||||
INSERT INTO public.invoice_payments
|
||||
(user_id, company_id, invoice_id, payment_date, amount, currency, exchange_rate,
|
||||
journal_entry_id, transaction_id)
|
||||
VALUES
|
||||
(p_user_id, p_company_id, v_invoice_id, v_tx.date, v_alloc_amount, v_invoice.currency,
|
||||
v_invoice.exchange_rate, v_journal_entry_id, p_tx_id)
|
||||
RETURNING id INTO v_payment_id;
|
||||
v_results := v_results || jsonb_build_array(jsonb_build_object(
|
||||
'kind', 'customer_invoice', 'invoice_id', v_invoice_id, 'payment_id', v_payment_id,
|
||||
'status', v_new_status, 'paid_amount', v_new_paid, 'remaining_amount', v_new_remaining,
|
||||
'amount', v_alloc_amount));
|
||||
ELSE
|
||||
v_supplier_invoice_id := (v_allocation->>'supplier_invoice_id')::uuid;
|
||||
SELECT * INTO v_si_invoice FROM public.supplier_invoices WHERE id = v_supplier_invoice_id;
|
||||
v_new_paid := ROUND((COALESCE(v_si_invoice.paid_amount, 0) + v_alloc_amount) * 100) / 100;
|
||||
v_new_remaining := GREATEST(0,
|
||||
ROUND((COALESCE(v_si_invoice.remaining_amount, v_si_invoice.total) - v_alloc_amount) * 100) / 100);
|
||||
v_new_status := CASE WHEN v_new_remaining <= 0.005 THEN 'paid' ELSE 'partially_paid' END;
|
||||
UPDATE public.supplier_invoices SET status = v_new_status,
|
||||
paid_at = CASE WHEN v_new_status = 'paid' THEN v_now ELSE paid_at END,
|
||||
paid_amount = v_new_paid, remaining_amount = v_new_remaining,
|
||||
payment_journal_entry_id = v_journal_entry_id, updated_at = v_now
|
||||
WHERE id = v_supplier_invoice_id;
|
||||
INSERT INTO public.supplier_invoice_payments
|
||||
(user_id, company_id, supplier_invoice_id, payment_date, amount, currency,
|
||||
journal_entry_id, transaction_id)
|
||||
VALUES
|
||||
(p_user_id, p_company_id, v_supplier_invoice_id, v_tx.date, v_alloc_amount,
|
||||
v_si_invoice.currency, v_journal_entry_id, p_tx_id)
|
||||
RETURNING id INTO v_payment_id;
|
||||
v_results := v_results || jsonb_build_array(jsonb_build_object(
|
||||
'kind', 'supplier_invoice', 'supplier_invoice_id', v_supplier_invoice_id,
|
||||
'payment_id', v_payment_id, 'status', v_new_status, 'paid_amount', v_new_paid,
|
||||
'remaining_amount', v_new_remaining, 'amount', v_alloc_amount));
|
||||
END IF;
|
||||
|
||||
v_alloc_index := v_alloc_index + 1;
|
||||
END LOOP;
|
||||
|
||||
UPDATE public.transactions SET journal_entry_id = v_journal_entry_id, is_business = TRUE,
|
||||
invoice_id = CASE WHEN jsonb_array_length(p_allocations) = 1 AND v_has_customer AND ABS(v_total_allocated - v_tx_abs) < 0.005
|
||||
THEN (p_allocations->0->>'invoice_id')::uuid ELSE NULL END,
|
||||
supplier_invoice_id = CASE WHEN jsonb_array_length(p_allocations) = 1 AND v_has_supplier AND ABS(v_total_allocated - v_tx_abs) < 0.005
|
||||
THEN (p_allocations->0->>'supplier_invoice_id')::uuid ELSE NULL END,
|
||||
potential_invoice_id = NULL, potential_supplier_invoice_id = NULL,
|
||||
category = CASE WHEN v_has_customer THEN 'income_services' ELSE category END,
|
||||
updated_at = v_now WHERE id = p_tx_id;
|
||||
|
||||
RETURN jsonb_build_object('ok', true, 'journal_entry_id', v_journal_entry_id,
|
||||
'voucher_series', v_voucher_series, 'voucher_number', v_voucher_number,
|
||||
'tx_id', p_tx_id, 'allocations', v_results, 'total_allocated', v_total_allocated,
|
||||
'leftover', ROUND((v_tx_abs - v_total_allocated) * 100) / 100);
|
||||
END;
|
||||
$$;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,345 @@
|
||||
-- PR #603 review round 2 — compliance + Swedish-accounting fixes.
|
||||
--
|
||||
-- 1. (GDPR Art.5(1)(f) / ISO A.8.2) Caller verification — SECURITY DEFINER
|
||||
-- bypasses RLS. Now require auth.uid() to be a member of p_company_id
|
||||
-- before any read or write. Pattern lifted from harden_invoice_number_rpcs
|
||||
-- (#20260510140000) and other security-hardened RPCs in this codebase.
|
||||
-- Returns structured BATCH_UNAUTHORIZED so the API layer can map cleanly.
|
||||
--
|
||||
-- 2. (Swedish-accounting) source_type — supplier batches now write
|
||||
-- 'supplier_invoice_paid' instead of 'invoice_paid'. Customer batches
|
||||
-- keep 'invoice_paid'. behandlingshistorik filters and report queries
|
||||
-- that route by source_type now see the correct channel.
|
||||
--
|
||||
-- 3. (Swedish-accounting) Fiscal-period lookup gets ORDER BY period_start DESC
|
||||
-- so an overlap (e.g. corrected broken year) deterministically picks the
|
||||
-- most recent period rather than an arbitrary one.
|
||||
--
|
||||
-- 4. (Swedish-accounting) Cross-allocation tolerance harmonised to 0.005
|
||||
-- (was 0.01). The per-allocation overshoot uses 0.005; matching the
|
||||
-- sum check prevents a two-row batch from each passing per-row while
|
||||
-- collectively drifting ~0.01 SEK.
|
||||
--
|
||||
-- 5. (Swedish-accounting) transactions.category no longer forced to
|
||||
-- 'income_services' for customer batches. The category is only valid
|
||||
-- 1:1 with a single invoice; for multi-invoice batches the existing
|
||||
-- category (typically 'uncategorized') is preserved, matching the
|
||||
-- supplier-side `ELSE category` branch.
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.match_batch_allocate(
|
||||
p_tx_id uuid,
|
||||
p_allocations 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_abs numeric;
|
||||
v_allocation jsonb;
|
||||
v_alloc_index int := 0;
|
||||
v_kind text;
|
||||
v_invoice_id uuid;
|
||||
v_supplier_invoice_id uuid;
|
||||
v_alloc_amount numeric;
|
||||
v_total_allocated numeric := 0;
|
||||
v_has_customer boolean := false;
|
||||
v_has_supplier boolean := false;
|
||||
v_seen_ids text[] := ARRAY[]::text[];
|
||||
v_target_id text;
|
||||
v_invoice RECORD;
|
||||
v_si_invoice RECORD;
|
||||
v_supplier_name text;
|
||||
v_supplier_invoice_number text;
|
||||
v_invoice_number text;
|
||||
v_fiscal_period_id uuid;
|
||||
v_period_is_closed boolean;
|
||||
v_period_locked_at timestamptz;
|
||||
v_journal_entry_id uuid := gen_random_uuid();
|
||||
v_voucher_series text := 'A';
|
||||
v_voucher_number int;
|
||||
v_entry_description text;
|
||||
v_source_type text;
|
||||
v_line_sort_order int := 0;
|
||||
v_new_paid numeric;
|
||||
v_new_remaining numeric;
|
||||
v_new_status text;
|
||||
v_now timestamptz := now();
|
||||
v_payment_id uuid;
|
||||
v_results jsonb := '[]'::jsonb;
|
||||
BEGIN
|
||||
-- (1) Caller membership check. SECURITY DEFINER means we run with elevated
|
||||
-- privileges; without this, an authenticated user could call the RPC with
|
||||
-- a p_company_id they don't belong to and the FOR UPDATE locks would still
|
||||
-- succeed. Pattern from 20260510140000_harden_invoice_number_rpcs.
|
||||
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', 'BATCH_UNAUTHORIZED');
|
||||
END IF;
|
||||
|
||||
SELECT * INTO v_tx FROM public.transactions
|
||||
WHERE id = p_tx_id AND company_id = p_company_id FOR UPDATE;
|
||||
IF NOT FOUND THEN RETURN jsonb_build_object('ok', false, 'code', 'BATCH_TX_NOT_FOUND'); END IF;
|
||||
IF v_tx.journal_entry_id IS NOT NULL THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_TX_ALREADY_BOOKED',
|
||||
'details', jsonb_build_object('journal_entry_id', v_tx.journal_entry_id));
|
||||
END IF;
|
||||
IF v_tx.amount = 0 THEN RETURN jsonb_build_object('ok', false, 'code', 'BATCH_TX_ZERO_AMOUNT'); END IF;
|
||||
v_tx_abs := ABS(v_tx.amount);
|
||||
IF jsonb_typeof(p_allocations) IS DISTINCT FROM 'array' OR jsonb_array_length(p_allocations) = 0 THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_NO_ALLOCATIONS');
|
||||
END IF;
|
||||
|
||||
FOR v_allocation IN
|
||||
SELECT value FROM jsonb_array_elements(p_allocations) AS t(value)
|
||||
ORDER BY COALESCE(value->>'invoice_id', value->>'supplier_invoice_id', '')
|
||||
LOOP
|
||||
v_kind := v_allocation->>'kind';
|
||||
v_alloc_amount := (v_allocation->>'amount')::numeric;
|
||||
v_target_id := COALESCE(v_allocation->>'invoice_id', v_allocation->>'supplier_invoice_id');
|
||||
IF v_alloc_amount IS NULL OR v_alloc_amount <= 0 THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_INVALID_AMOUNT',
|
||||
'details', jsonb_build_object('index', v_alloc_index, 'amount', v_alloc_amount));
|
||||
END IF;
|
||||
IF v_target_id IS NOT NULL AND v_target_id = ANY(v_seen_ids) THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_DUPLICATE_ALLOCATION',
|
||||
'details', jsonb_build_object('id', v_target_id, 'index', v_alloc_index));
|
||||
END IF;
|
||||
IF v_target_id IS NOT NULL THEN v_seen_ids := array_append(v_seen_ids, v_target_id); END IF;
|
||||
v_total_allocated := v_total_allocated + v_alloc_amount;
|
||||
IF v_kind = 'customer_invoice' THEN
|
||||
v_has_customer := true;
|
||||
v_invoice_id := (v_allocation->>'invoice_id')::uuid;
|
||||
SELECT * INTO v_invoice FROM public.invoices
|
||||
WHERE id = v_invoice_id AND company_id = p_company_id FOR UPDATE;
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_INVOICE_NOT_FOUND',
|
||||
'details', jsonb_build_object('index', v_alloc_index, 'invoice_id', v_invoice_id));
|
||||
END IF;
|
||||
IF v_invoice.status NOT IN ('sent', 'overdue', 'partially_paid') THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_INVOICE_NOT_OPEN',
|
||||
'details', jsonb_build_object('index', v_alloc_index, 'invoice_id', v_invoice_id, 'status', v_invoice.status));
|
||||
END IF;
|
||||
IF v_alloc_amount > COALESCE(v_invoice.remaining_amount, v_invoice.total) + 0.005 THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_OVERSHOOT',
|
||||
'details', jsonb_build_object('index', v_alloc_index, 'invoice_id', v_invoice_id,
|
||||
'requested', v_alloc_amount, 'remaining', COALESCE(v_invoice.remaining_amount, v_invoice.total)));
|
||||
END IF;
|
||||
IF v_invoice.currency IS DISTINCT FROM v_tx.currency THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_CURRENCY_MISMATCH',
|
||||
'details', jsonb_build_object('index', v_alloc_index, 'invoice_id', v_invoice_id,
|
||||
'invoice_currency', v_invoice.currency, 'tx_currency', v_tx.currency));
|
||||
END IF;
|
||||
ELSIF v_kind = 'supplier_invoice' THEN
|
||||
v_has_supplier := true;
|
||||
v_supplier_invoice_id := (v_allocation->>'supplier_invoice_id')::uuid;
|
||||
SELECT * INTO v_si_invoice FROM public.supplier_invoices
|
||||
WHERE id = v_supplier_invoice_id AND company_id = p_company_id FOR UPDATE;
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_SUPPLIER_INVOICE_NOT_FOUND',
|
||||
'details', jsonb_build_object('index', v_alloc_index, 'supplier_invoice_id', v_supplier_invoice_id));
|
||||
END IF;
|
||||
IF v_si_invoice.status NOT IN ('registered', 'approved', 'overdue', 'partially_paid') THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_SUPPLIER_INVOICE_NOT_OPEN',
|
||||
'details', jsonb_build_object('index', v_alloc_index, 'supplier_invoice_id', v_supplier_invoice_id, 'status', v_si_invoice.status));
|
||||
END IF;
|
||||
IF v_alloc_amount > COALESCE(v_si_invoice.remaining_amount, v_si_invoice.total) + 0.005 THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_OVERSHOOT',
|
||||
'details', jsonb_build_object('index', v_alloc_index, 'supplier_invoice_id', v_supplier_invoice_id,
|
||||
'requested', v_alloc_amount, 'remaining', COALESCE(v_si_invoice.remaining_amount, v_si_invoice.total)));
|
||||
END IF;
|
||||
IF v_si_invoice.currency IS DISTINCT FROM v_tx.currency THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_CURRENCY_MISMATCH',
|
||||
'details', jsonb_build_object('index', v_alloc_index, 'supplier_invoice_id', v_supplier_invoice_id,
|
||||
'invoice_currency', v_si_invoice.currency, 'tx_currency', v_tx.currency));
|
||||
END IF;
|
||||
ELSE
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_INVALID_KIND',
|
||||
'details', jsonb_build_object('index', v_alloc_index, 'kind', v_kind));
|
||||
END IF;
|
||||
v_alloc_index := v_alloc_index + 1;
|
||||
END LOOP;
|
||||
|
||||
IF v_has_customer AND v_has_supplier THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_MIXED_KINDS_UNSUPPORTED');
|
||||
END IF;
|
||||
|
||||
-- (4) Cross-allocation tolerance was 0.01 — harmonised to 0.005 to match
|
||||
-- the per-row overshoot guard and prevent a 1-öre drift accumulating
|
||||
-- across multi-allocation batches.
|
||||
IF v_total_allocated > v_tx_abs + 0.005 THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_AMOUNT_EXCEEDS_TX',
|
||||
'details', jsonb_build_object('allocated', v_total_allocated, 'tx_amount_abs', v_tx_abs));
|
||||
END IF;
|
||||
|
||||
IF v_has_customer AND v_tx.amount <= 0 THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_DIRECTION_MISMATCH',
|
||||
'details', jsonb_build_object('expected', 'income', 'tx_amount', v_tx.amount));
|
||||
END IF;
|
||||
IF v_has_supplier AND v_tx.amount >= 0 THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_DIRECTION_MISMATCH',
|
||||
'details', jsonb_build_object('expected', 'expense', 'tx_amount', v_tx.amount));
|
||||
END IF;
|
||||
|
||||
-- (3) Deterministic period selection on overlap. ORDER BY period_start DESC
|
||||
-- picks the most recent matching period when, e.g., a corrected broken year
|
||||
-- has two periods covering the same date.
|
||||
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', 'BATCH_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', 'BATCH_PERIOD_LOCKED',
|
||||
'details', jsonb_build_object('fiscal_period_id', v_fiscal_period_id,
|
||||
'is_closed', v_period_is_closed, 'locked_at', v_period_locked_at));
|
||||
END IF;
|
||||
|
||||
v_entry_description := CASE WHEN v_has_customer THEN 'Samlingsinbetalning ' || v_tx.date ELSE 'Samlingsbetalning ' || v_tx.date END;
|
||||
|
||||
-- (2) Direction-specific source_type so behandlingshistorik and
|
||||
-- source-type filters route correctly.
|
||||
v_source_type := CASE WHEN v_has_customer THEN 'invoice_paid' ELSE 'supplier_invoice_paid' END;
|
||||
|
||||
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, v_source_type, 'draft');
|
||||
|
||||
v_alloc_index := 0;
|
||||
FOR v_allocation IN
|
||||
SELECT value FROM jsonb_array_elements(p_allocations) AS t(value)
|
||||
ORDER BY COALESCE(value->>'invoice_id', value->>'supplier_invoice_id', '')
|
||||
LOOP
|
||||
v_alloc_amount := (v_allocation->>'amount')::numeric;
|
||||
IF v_has_customer THEN
|
||||
v_invoice_id := (v_allocation->>'invoice_id')::uuid;
|
||||
SELECT invoice_number INTO v_invoice_number FROM public.invoices WHERE id = v_invoice_id;
|
||||
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, '1510', 0, v_alloc_amount, v_tx.currency, v_line_sort_order,
|
||||
'Faktura ' || COALESCE(v_invoice_number, ''));
|
||||
ELSE
|
||||
v_supplier_invoice_id := (v_allocation->>'supplier_invoice_id')::uuid;
|
||||
SELECT si.supplier_invoice_number, s.name
|
||||
INTO v_supplier_invoice_number, v_supplier_name
|
||||
FROM public.supplier_invoices si LEFT JOIN public.suppliers s ON s.id = si.supplier_id
|
||||
WHERE si.id = v_supplier_invoice_id;
|
||||
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, '2440', v_alloc_amount, 0, v_tx.currency, v_line_sort_order,
|
||||
TRIM(BOTH ' - ' FROM COALESCE(v_supplier_name, '') || ' - ' || COALESCE(v_supplier_invoice_number, '')));
|
||||
END IF;
|
||||
v_line_sort_order := v_line_sort_order + 1;
|
||||
v_alloc_index := v_alloc_index + 1;
|
||||
END LOOP;
|
||||
|
||||
IF v_has_customer THEN
|
||||
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, '1930', v_total_allocated, 0, v_tx.currency, v_line_sort_order,
|
||||
'Inbetalning ' || v_tx.date);
|
||||
ELSE
|
||||
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, '1930', 0, v_total_allocated, v_tx.currency, v_line_sort_order,
|
||||
'Utbetalning ' || v_tx.date);
|
||||
END IF;
|
||||
|
||||
SELECT voucher_number INTO v_voucher_number FROM public.commit_journal_entry(p_company_id, v_journal_entry_id);
|
||||
|
||||
v_alloc_index := 0;
|
||||
FOR v_allocation IN
|
||||
SELECT value FROM jsonb_array_elements(p_allocations) AS t(value)
|
||||
ORDER BY COALESCE(value->>'invoice_id', value->>'supplier_invoice_id', '')
|
||||
LOOP
|
||||
v_alloc_amount := (v_allocation->>'amount')::numeric;
|
||||
IF v_has_customer THEN
|
||||
v_invoice_id := (v_allocation->>'invoice_id')::uuid;
|
||||
SELECT * INTO v_invoice FROM public.invoices WHERE id = v_invoice_id;
|
||||
v_new_paid := ROUND((COALESCE(v_invoice.paid_amount, 0) + v_alloc_amount) * 100) / 100;
|
||||
v_new_remaining := GREATEST(0,
|
||||
ROUND((COALESCE(v_invoice.remaining_amount, v_invoice.total) - v_alloc_amount) * 100) / 100);
|
||||
v_new_status := CASE WHEN v_new_remaining <= 0.005 THEN 'paid' ELSE 'partially_paid' END;
|
||||
UPDATE public.invoices SET status = v_new_status,
|
||||
paid_at = CASE WHEN v_new_status = 'paid' THEN v_now ELSE paid_at END,
|
||||
paid_amount = v_new_paid, remaining_amount = v_new_remaining, updated_at = v_now
|
||||
WHERE id = v_invoice_id;
|
||||
INSERT INTO public.invoice_payments
|
||||
(user_id, company_id, invoice_id, payment_date, amount, currency, exchange_rate,
|
||||
journal_entry_id, transaction_id)
|
||||
VALUES
|
||||
(p_user_id, p_company_id, v_invoice_id, v_tx.date, v_alloc_amount, v_invoice.currency,
|
||||
v_invoice.exchange_rate, v_journal_entry_id, p_tx_id)
|
||||
RETURNING id INTO v_payment_id;
|
||||
v_results := v_results || jsonb_build_array(jsonb_build_object(
|
||||
'kind', 'customer_invoice', 'invoice_id', v_invoice_id, 'payment_id', v_payment_id,
|
||||
'status', v_new_status, 'paid_amount', v_new_paid, 'remaining_amount', v_new_remaining,
|
||||
'amount', v_alloc_amount));
|
||||
ELSE
|
||||
v_supplier_invoice_id := (v_allocation->>'supplier_invoice_id')::uuid;
|
||||
SELECT * INTO v_si_invoice FROM public.supplier_invoices WHERE id = v_supplier_invoice_id;
|
||||
v_new_paid := ROUND((COALESCE(v_si_invoice.paid_amount, 0) + v_alloc_amount) * 100) / 100;
|
||||
v_new_remaining := GREATEST(0,
|
||||
ROUND((COALESCE(v_si_invoice.remaining_amount, v_si_invoice.total) - v_alloc_amount) * 100) / 100);
|
||||
v_new_status := CASE WHEN v_new_remaining <= 0.005 THEN 'paid' ELSE 'partially_paid' END;
|
||||
UPDATE public.supplier_invoices SET status = v_new_status,
|
||||
paid_at = CASE WHEN v_new_status = 'paid' THEN v_now ELSE paid_at END,
|
||||
paid_amount = v_new_paid, remaining_amount = v_new_remaining,
|
||||
payment_journal_entry_id = v_journal_entry_id, updated_at = v_now
|
||||
WHERE id = v_supplier_invoice_id;
|
||||
INSERT INTO public.supplier_invoice_payments
|
||||
(user_id, company_id, supplier_invoice_id, payment_date, amount, currency,
|
||||
journal_entry_id, transaction_id)
|
||||
VALUES
|
||||
(p_user_id, p_company_id, v_supplier_invoice_id, v_tx.date, v_alloc_amount,
|
||||
v_si_invoice.currency, v_journal_entry_id, p_tx_id)
|
||||
RETURNING id INTO v_payment_id;
|
||||
v_results := v_results || jsonb_build_array(jsonb_build_object(
|
||||
'kind', 'supplier_invoice', 'supplier_invoice_id', v_supplier_invoice_id,
|
||||
'payment_id', v_payment_id, 'status', v_new_status, 'paid_amount', v_new_paid,
|
||||
'remaining_amount', v_new_remaining, 'amount', v_alloc_amount));
|
||||
END IF;
|
||||
v_alloc_index := v_alloc_index + 1;
|
||||
END LOOP;
|
||||
|
||||
-- (5) transactions.category is only meaningful 1:1 with a single invoice.
|
||||
-- Forcing 'income_services' (→ BAS 3001 at 25% VAT) for multi-allocation
|
||||
-- customer batches misrepresents reduced/zero-rated revenue. For
|
||||
-- single-allocation full-amount batches the existing
|
||||
-- match-invoice/match-supplier-invoice path is the canonical writer;
|
||||
-- this RPC leaves the category as-is.
|
||||
UPDATE public.transactions SET journal_entry_id = v_journal_entry_id, is_business = TRUE,
|
||||
invoice_id = CASE WHEN jsonb_array_length(p_allocations) = 1 AND v_has_customer AND ABS(v_total_allocated - v_tx_abs) < 0.005
|
||||
THEN (p_allocations->0->>'invoice_id')::uuid ELSE NULL END,
|
||||
supplier_invoice_id = CASE WHEN jsonb_array_length(p_allocations) = 1 AND v_has_supplier AND ABS(v_total_allocated - v_tx_abs) < 0.005
|
||||
THEN (p_allocations->0->>'supplier_invoice_id')::uuid ELSE NULL END,
|
||||
potential_invoice_id = NULL, potential_supplier_invoice_id = NULL,
|
||||
updated_at = v_now WHERE id = p_tx_id;
|
||||
|
||||
RETURN jsonb_build_object('ok', true, 'journal_entry_id', v_journal_entry_id,
|
||||
'voucher_series', v_voucher_series, 'voucher_number', v_voucher_number,
|
||||
'tx_id', p_tx_id, 'allocations', v_results, 'total_allocated', v_total_allocated,
|
||||
'leftover', ROUND((v_tx_abs - v_total_allocated) * 100) / 100);
|
||||
END;
|
||||
$$;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,462 @@
|
||||
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 20260529120100_match_batch_allocate:
|
||||
* - 1 bank tx → N supplier invoices: builds ONE combined verifikat with
|
||||
* N × Dr 2440 + 1 × Cr 1930, inserts N supplier_invoice_payments rows
|
||||
* all pointing at the same JE.
|
||||
* - Per-invoice paid_amount/remaining_amount/status advance correctly.
|
||||
* - Overshoot guard returns BATCH_OVERSHOOT cleanly (no partial state).
|
||||
* - Already-booked tx rejection.
|
||||
* - Direction mismatch rejection.
|
||||
* - Mixed customer + supplier kinds rejection.
|
||||
*
|
||||
* These tests bypass RLS by writing through the superuser pool — they
|
||||
* exercise the RPC logic + DB constraints, not the policy layer.
|
||||
*/
|
||||
|
||||
async function insertSupplier(params: {
|
||||
userId: string
|
||||
companyId: string
|
||||
name?: string
|
||||
}): Promise<string> {
|
||||
const id = randomUUID()
|
||||
await getPool().query(
|
||||
`INSERT INTO public.suppliers
|
||||
(id, user_id, company_id, name, supplier_type, country, default_payment_terms, default_currency)
|
||||
VALUES ($1, $2, $3, $4, 'swedish_business', 'SE', 30, 'SEK')`,
|
||||
[id, params.userId, params.companyId, params.name ?? 'Leverantör AB'],
|
||||
)
|
||||
return id
|
||||
}
|
||||
|
||||
async function insertSupplierInvoice(params: {
|
||||
userId: string
|
||||
companyId: string
|
||||
supplierId: string
|
||||
total: number
|
||||
status?: string
|
||||
invoiceDate?: string
|
||||
dueDate?: string
|
||||
}): Promise<string> {
|
||||
const id = randomUUID()
|
||||
// Arrival numbers are generated per-company by get_next_arrival_number,
|
||||
// but for an isolated test we can hardcode a unique value via current time
|
||||
// millis modulo a wide range. The unique constraint allows that.
|
||||
const arrivalNumber = (Date.now() % 1_000_000_000) + Math.floor(Math.random() * 10_000)
|
||||
await getPool().query(
|
||||
`INSERT INTO public.supplier_invoices
|
||||
(id, user_id, company_id, supplier_id, arrival_number, supplier_invoice_number,
|
||||
invoice_date, due_date, received_date, status, currency,
|
||||
subtotal, vat_amount, total, paid_amount, remaining_amount,
|
||||
vat_treatment, reverse_charge, is_credit_note)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $7, $9, 'SEK',
|
||||
$10, 0, $10, 0, $10, 'standard_25', false, false)`,
|
||||
[
|
||||
id,
|
||||
params.userId,
|
||||
params.companyId,
|
||||
params.supplierId,
|
||||
arrivalNumber,
|
||||
`LF-${arrivalNumber}`,
|
||||
params.invoiceDate ?? '2026-06-01',
|
||||
params.dueDate ?? '2026-07-01',
|
||||
params.status ?? 'approved',
|
||||
params.total,
|
||||
],
|
||||
)
|
||||
return id
|
||||
}
|
||||
|
||||
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, $5, $6, $7, 'uncategorized')`,
|
||||
[
|
||||
id,
|
||||
params.userId,
|
||||
params.companyId,
|
||||
params.date ?? '2026-06-05',
|
||||
'Bank transfer',
|
||||
params.amount,
|
||||
params.currency ?? 'SEK',
|
||||
],
|
||||
)
|
||||
return id
|
||||
}
|
||||
|
||||
async function seedTenant(opts: { isClosed?: boolean } = {}) {
|
||||
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',
|
||||
isClosed: opts.isClosed,
|
||||
})
|
||||
return { userId, companyId, fiscalPeriodId }
|
||||
}
|
||||
|
||||
interface RpcResult {
|
||||
ok: boolean
|
||||
code?: string
|
||||
details?: Record<string, unknown>
|
||||
journal_entry_id?: string
|
||||
voucher_number?: number
|
||||
allocations?: Array<{
|
||||
kind: string
|
||||
supplier_invoice_id?: string
|
||||
invoice_id?: string
|
||||
payment_id: string
|
||||
status: string
|
||||
paid_amount: number
|
||||
remaining_amount: number
|
||||
amount: number
|
||||
}>
|
||||
total_allocated?: number
|
||||
leftover?: number
|
||||
}
|
||||
|
||||
describe('match_batch_allocate', () => {
|
||||
it('builds a single combined verifikat for 1 tx → 3 supplier invoices', async () => {
|
||||
const { userId, companyId } = await seedTenant()
|
||||
const supplier = await insertSupplier({ userId, companyId })
|
||||
|
||||
const si1 = await insertSupplierInvoice({
|
||||
userId, companyId, supplierId: supplier, total: 2000,
|
||||
})
|
||||
const si2 = await insertSupplierInvoice({
|
||||
userId, companyId, supplierId: supplier, total: 3000,
|
||||
})
|
||||
const si3 = await insertSupplierInvoice({
|
||||
userId, companyId, supplierId: supplier, total: 1500,
|
||||
})
|
||||
|
||||
const txId = await insertTransaction({
|
||||
userId, companyId, amount: -6500, date: '2026-06-05',
|
||||
})
|
||||
|
||||
const allocations = [
|
||||
{ kind: 'supplier_invoice', supplier_invoice_id: si1, amount: 2000 },
|
||||
{ kind: 'supplier_invoice', supplier_invoice_id: si2, amount: 3000 },
|
||||
{ kind: 'supplier_invoice', supplier_invoice_id: si3, amount: 1500 },
|
||||
]
|
||||
|
||||
// withUserContext sets request.jwt.claim.sub so the RPC's auth.uid()
|
||||
// membership check (PR #603 round 2) resolves the seeded owner.
|
||||
// ALL assertions about post-RPC state must run inside this block since
|
||||
// it rolls back at the end.
|
||||
await withUserContext(userId, async (client) => {
|
||||
const r = await client.query<{ match_batch_allocate: RpcResult }>(
|
||||
`SELECT match_batch_allocate($1, $2::jsonb, $3, $4)`,
|
||||
[txId, JSON.stringify(allocations), userId, companyId],
|
||||
)
|
||||
const result = r.rows[0]!.match_batch_allocate
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
expect(result.journal_entry_id).toBeTruthy()
|
||||
expect(result.voucher_number).toBeGreaterThan(0)
|
||||
expect(result.total_allocated).toBe(6500)
|
||||
expect(result.leftover).toBe(0)
|
||||
expect(result.allocations).toHaveLength(3)
|
||||
|
||||
// Verify one verifikat with N+1 lines (3 × Dr 2440 + 1 × Cr 1930).
|
||||
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(4)
|
||||
const apLines = lines.rows.filter((l) => l.account_number === '2440')
|
||||
const bankLines = lines.rows.filter((l) => l.account_number === '1930')
|
||||
expect(apLines).toHaveLength(3)
|
||||
expect(bankLines).toHaveLength(1)
|
||||
expect(Number(bankLines[0]!.credit_amount)).toBe(6500)
|
||||
const apSum = apLines.reduce((s, l) => s + Number(l.debit_amount), 0)
|
||||
expect(apSum).toBe(6500)
|
||||
|
||||
// Verify all 3 supplier invoices flipped to 'paid'.
|
||||
const inv1 = await client.query<{ status: string; paid_amount: string; remaining_amount: string }>(
|
||||
`SELECT status, paid_amount, remaining_amount FROM public.supplier_invoices WHERE id = $1`,
|
||||
[si1],
|
||||
)
|
||||
expect(inv1.rows[0]!.status).toBe('paid')
|
||||
expect(Number(inv1.rows[0]!.paid_amount)).toBe(2000)
|
||||
expect(Number(inv1.rows[0]!.remaining_amount)).toBe(0)
|
||||
|
||||
// Verify 3 supplier_invoice_payments rows all reference the same JE.
|
||||
const payments = await client.query<{ journal_entry_id: string; supplier_invoice_id: string }>(
|
||||
`SELECT journal_entry_id, supplier_invoice_id
|
||||
FROM public.supplier_invoice_payments WHERE transaction_id = $1`,
|
||||
[txId],
|
||||
)
|
||||
expect(payments.rows).toHaveLength(3)
|
||||
const jeIds = new Set(payments.rows.map((p) => p.journal_entry_id))
|
||||
expect(jeIds.size).toBe(1)
|
||||
expect(jeIds.has(result.journal_entry_id!)).toBe(true)
|
||||
|
||||
// Verify tx.journal_entry_id is set + supplier_invoice_id left NULL (multi).
|
||||
const txRow = await client.query<{
|
||||
journal_entry_id: string | null
|
||||
supplier_invoice_id: string | null
|
||||
is_business: boolean
|
||||
}>(
|
||||
`SELECT journal_entry_id, supplier_invoice_id, is_business
|
||||
FROM public.transactions WHERE id = $1`,
|
||||
[txId],
|
||||
)
|
||||
expect(txRow.rows[0]!.journal_entry_id).toBe(result.journal_entry_id)
|
||||
expect(txRow.rows[0]!.supplier_invoice_id).toBeNull()
|
||||
expect(txRow.rows[0]!.is_business).toBe(true)
|
||||
|
||||
// Verify samlingsverifikat carries the supplier-side source_type
|
||||
// (PR #603 compliance fix — was previously 'invoice_paid' for both
|
||||
// directions which mis-routed behandlingshistorik filters).
|
||||
const je = await client.query<{ source_type: string }>(
|
||||
`SELECT source_type FROM public.journal_entries WHERE id = $1`,
|
||||
[result.journal_entry_id],
|
||||
)
|
||||
expect(je.rows[0]!.source_type).toBe('supplier_invoice_paid')
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects with BATCH_OVERSHOOT when allocation exceeds invoice remaining', async () => {
|
||||
const { userId, companyId } = await seedTenant()
|
||||
const supplier = await insertSupplier({ userId, companyId })
|
||||
const si = await insertSupplierInvoice({
|
||||
userId, companyId, supplierId: supplier, total: 1000,
|
||||
})
|
||||
const txId = await insertTransaction({ userId, companyId, amount: -5000 })
|
||||
|
||||
const allocations = [
|
||||
{ kind: 'supplier_invoice', supplier_invoice_id: si, amount: 5000 },
|
||||
]
|
||||
|
||||
await withUserContext(userId, async (client) => {
|
||||
const r = await client.query<{ match_batch_allocate: RpcResult }>(
|
||||
`SELECT match_batch_allocate($1, $2::jsonb, $3, $4)`,
|
||||
[txId, JSON.stringify(allocations), userId, companyId],
|
||||
)
|
||||
const result = r.rows[0]!.match_batch_allocate
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
expect(result.code).toBe('BATCH_OVERSHOOT')
|
||||
expect(result.details).toMatchObject({ supplier_invoice_id: si, requested: 5000 })
|
||||
|
||||
// No journal entry should have been created.
|
||||
const txRow = await client.query<{ journal_entry_id: string | null }>(
|
||||
`SELECT journal_entry_id FROM public.transactions WHERE id = $1`,
|
||||
[txId],
|
||||
)
|
||||
expect(txRow.rows[0]!.journal_entry_id).toBeNull()
|
||||
|
||||
const inv = await client.query<{ paid_amount: string; remaining_amount: string }>(
|
||||
`SELECT paid_amount, remaining_amount FROM public.supplier_invoices WHERE id = $1`,
|
||||
[si],
|
||||
)
|
||||
expect(Number(inv.rows[0]!.paid_amount)).toBe(0)
|
||||
expect(Number(inv.rows[0]!.remaining_amount)).toBe(1000)
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects with BATCH_UNAUTHORIZED when caller is not a member of the company', async () => {
|
||||
const { userId, companyId } = await seedTenant()
|
||||
const supplier = await insertSupplier({ userId, companyId })
|
||||
const si = await insertSupplierInvoice({
|
||||
userId, companyId, supplierId: supplier, total: 1000,
|
||||
})
|
||||
const txId = await insertTransaction({ userId, companyId, amount: -1000 })
|
||||
|
||||
// Different user — never added to company_members for companyId. The
|
||||
// SECURITY DEFINER check (PR #603 compliance) refuses any access.
|
||||
const outsiderId = await insertAuthUser()
|
||||
|
||||
await withUserContext(outsiderId, async (client) => {
|
||||
const r = await client.query<{ match_batch_allocate: RpcResult }>(
|
||||
`SELECT match_batch_allocate($1, $2::jsonb, $3, $4)`,
|
||||
[
|
||||
txId,
|
||||
JSON.stringify([{ kind: 'supplier_invoice', supplier_invoice_id: si, amount: 1000 }]),
|
||||
outsiderId,
|
||||
companyId,
|
||||
],
|
||||
)
|
||||
const result = r.rows[0]!.match_batch_allocate
|
||||
expect(result.ok).toBe(false)
|
||||
expect(result.code).toBe('BATCH_UNAUTHORIZED')
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects with BATCH_TX_ALREADY_BOOKED when tx already has a JE', async () => {
|
||||
const { userId, companyId, fiscalPeriodId } = await seedTenant()
|
||||
const supplier = await insertSupplier({ userId, companyId })
|
||||
const si = await insertSupplierInvoice({
|
||||
userId, companyId, supplierId: supplier, total: 1000,
|
||||
})
|
||||
|
||||
// Pre-book the tx by linking it to a manual posted JE.
|
||||
const existingJeId = 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')`,
|
||||
[existingJeId, userId, companyId, fiscalPeriodId],
|
||||
)
|
||||
await getPool().query(
|
||||
`INSERT INTO public.journal_entry_lines (journal_entry_id, account_number, debit_amount, credit_amount)
|
||||
VALUES ($1, '1930', 0, 1000), ($1, '4010', 1000, 0)`,
|
||||
[existingJeId],
|
||||
)
|
||||
await getPool().query(`UPDATE public.journal_entries SET status = 'posted' WHERE id = $1`, [existingJeId])
|
||||
|
||||
const txId = await insertTransaction({ userId, companyId, amount: -1000 })
|
||||
await getPool().query(
|
||||
`UPDATE public.transactions SET journal_entry_id = $1 WHERE id = $2`,
|
||||
[existingJeId, txId],
|
||||
)
|
||||
|
||||
const allocations = [
|
||||
{ kind: 'supplier_invoice', supplier_invoice_id: si, amount: 1000 },
|
||||
]
|
||||
|
||||
await withUserContext(userId, async (client) => {
|
||||
const r = await client.query<{ match_batch_allocate: RpcResult }>(
|
||||
`SELECT match_batch_allocate($1, $2::jsonb, $3, $4)`,
|
||||
[txId, JSON.stringify(allocations), userId, companyId],
|
||||
)
|
||||
const result = r.rows[0]!.match_batch_allocate
|
||||
expect(result.ok).toBe(false)
|
||||
expect(result.code).toBe('BATCH_TX_ALREADY_BOOKED')
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects with BATCH_DIRECTION_MISMATCH for supplier allocation against income tx', async () => {
|
||||
const { userId, companyId } = await seedTenant()
|
||||
const supplier = await insertSupplier({ userId, companyId })
|
||||
const si = await insertSupplierInvoice({
|
||||
userId, companyId, supplierId: supplier, total: 1000,
|
||||
})
|
||||
|
||||
// Positive tx (income) — wrong direction for supplier_invoice allocation.
|
||||
const txId = await insertTransaction({ userId, companyId, amount: 1000 })
|
||||
|
||||
await withUserContext(userId, async (client) => {
|
||||
const r = await client.query<{ match_batch_allocate: RpcResult }>(
|
||||
`SELECT match_batch_allocate($1, $2::jsonb, $3, $4)`,
|
||||
[
|
||||
txId,
|
||||
JSON.stringify([{ kind: 'supplier_invoice', supplier_invoice_id: si, amount: 1000 }]),
|
||||
userId,
|
||||
companyId,
|
||||
],
|
||||
)
|
||||
const result = r.rows[0]!.match_batch_allocate
|
||||
expect(result.ok).toBe(false)
|
||||
expect(result.code).toBe('BATCH_DIRECTION_MISMATCH')
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects BATCH_DUPLICATE_ALLOCATION when the same supplier invoice appears twice', async () => {
|
||||
const { userId, companyId } = await seedTenant()
|
||||
const supplier = await insertSupplier({ userId, companyId })
|
||||
const si = await insertSupplierInvoice({
|
||||
userId, companyId, supplierId: supplier, total: 1000,
|
||||
})
|
||||
const txId = await insertTransaction({ userId, companyId, amount: -800 })
|
||||
|
||||
// Same supplier_invoice_id listed twice. Per-allocation amounts (400 each)
|
||||
// do not individually overshoot the 1 000 remaining, but their sum would
|
||||
// insert two payment rows for one invoice. The dedupe guard catches
|
||||
// this in the validation loop before any write.
|
||||
const allocations = [
|
||||
{ kind: 'supplier_invoice', supplier_invoice_id: si, amount: 400 },
|
||||
{ kind: 'supplier_invoice', supplier_invoice_id: si, amount: 400 },
|
||||
]
|
||||
|
||||
await withUserContext(userId, async (client) => {
|
||||
const r = await client.query<{ match_batch_allocate: RpcResult }>(
|
||||
`SELECT match_batch_allocate($1, $2::jsonb, $3, $4)`,
|
||||
[txId, JSON.stringify(allocations), userId, companyId],
|
||||
)
|
||||
const result = r.rows[0]!.match_batch_allocate
|
||||
expect(result.ok).toBe(false)
|
||||
expect(result.code).toBe('BATCH_DUPLICATE_ALLOCATION')
|
||||
expect(result.details?.id).toBe(si)
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects BATCH_MIXED_KINDS_UNSUPPORTED on customer + supplier in same batch', async () => {
|
||||
const { userId, companyId } = await seedTenant()
|
||||
const supplier = await insertSupplier({ userId, companyId })
|
||||
const si = await insertSupplierInvoice({
|
||||
userId, companyId, supplierId: supplier, total: 1000,
|
||||
})
|
||||
|
||||
// Insert a customer + invoice for the customer-side allocation.
|
||||
const customerId = randomUUID()
|
||||
await getPool().query(
|
||||
`INSERT INTO public.customers
|
||||
(id, user_id, company_id, name, customer_type, country)
|
||||
VALUES ($1, $2, $3, 'Kund AB', 'swedish_business', 'SE')`,
|
||||
[customerId, userId, companyId],
|
||||
)
|
||||
const invoiceId = randomUUID()
|
||||
await getPool().query(
|
||||
`INSERT INTO public.invoices
|
||||
(id, user_id, company_id, customer_id, invoice_number, invoice_date, due_date, status,
|
||||
currency, subtotal, vat_amount, total, paid_amount, remaining_amount, vat_treatment)
|
||||
VALUES ($1, $2, $3, $4, 'F-001', '2026-06-01', '2026-07-01', 'sent', 'SEK',
|
||||
1000, 0, 1000, 0, 1000, 'standard_25')`,
|
||||
[invoiceId, userId, companyId, customerId],
|
||||
)
|
||||
|
||||
// Negative tx — direction makes both sides individually plausible, but
|
||||
// we reject mixed kinds outright. Actually negative=supplier and we need
|
||||
// either income or expense; the mixed check fires before the direction
|
||||
// check, so the result code is MIXED_KINDS regardless.
|
||||
const txId = await insertTransaction({ userId, companyId, amount: -2000 })
|
||||
|
||||
await withUserContext(userId, async (client) => {
|
||||
const r = await client.query<{ match_batch_allocate: RpcResult }>(
|
||||
`SELECT match_batch_allocate($1, $2::jsonb, $3, $4)`,
|
||||
[
|
||||
txId,
|
||||
JSON.stringify([
|
||||
{ kind: 'supplier_invoice', supplier_invoice_id: si, amount: 1000 },
|
||||
{ kind: 'customer_invoice', invoice_id: invoiceId, amount: 1000 },
|
||||
]),
|
||||
userId,
|
||||
companyId,
|
||||
],
|
||||
)
|
||||
const result = r.rows[0]!.match_batch_allocate
|
||||
expect(result.ok).toBe(false)
|
||||
expect(result.code).toBe('BATCH_MIXED_KINDS_UNSUPPORTED')
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user