Files
accounted/supabase/migrations/20260529120100_match_batch_allocate.sql
T
Jakob Wennberg 7eb8715417 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>
2026-05-29 13:45:41 +02:00

529 lines
20 KiB
PL/PgSQL
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
-- 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';