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:
Jakob Wennberg
2026-05-29 13:45:41 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 7bcd46d503
commit 7eb8715417
15 changed files with 2844 additions and 12 deletions
@@ -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';