feat(transactions): match overshoot guards + supplier voucher linking (#602)

* feat(transactions): match overshoot guards + supplier voucher linking

Three changes that together close the "I can't link a bank transaction
to an already-booked verifikat on the supplier side" gap and fix a
latent data-corruption bug on the per-tx match endpoints.

1. fix: clamp paid_amount on match endpoints when tx > remaining

   /api/transactions/[id]/match-{invoice,supplier-invoice} previously
   used transaction.amount wholesale as the paid amount, pushing
   invoice.paid_amount past invoice.total whenever the bank tx was
   larger than what was owed. Both endpoints now reject with
   MATCH_AMOUNT_EXCEEDS_REMAINING / MATCH_SI_AMOUNT_EXCEEDS_REMAINING
   and a structured { transaction_amount, remaining_amount, excess }
   payload that points the user at the future split-payment flow.
   FX branch already clamps to invoice.remaining_amount and is
   unchanged.

2. feat: supplier-side "link existing verifikat" (mirror of #591)

   lib/invoices/supplier-voucher-matching.ts mirrors the customer
   voucher-matching module: finds posted JEs that debit 2440
   (Leverantörsskulder), validates currency + remaining-amount, and
   atomically links them as supplier_invoice_payments rows. New
   /api/supplier-invoices/[id]/{voucher-candidates,link-to-voucher}
   routes wrap it. LinkVoucherPicker gains a mode='supplier_invoice'
   prop so the same component renders both flows. The supplier-invoice
   mark-paid dialog now uses Tabs ("Ny betalning" / "Befintlig
   verifikation") to match the customer-side UX.

3. infra: transaction_voucher_links junction + denorm guard

   Foundation migration for upcoming multi-tx ↔ multi-voucher flows.
   Adds the junction table (with RLS, updated_at, indexes), a
   block_contradictory_invoice_denorm trigger on transactions that
   refuses to set invoice_id/supplier_invoice_id to a value that
   contradicts an existing payment row, and is_transaction_booked(uuid)
   as a single source of truth for "is this tx anchored?" once
   multi-allocation leaves denorm columns NULL. No application code
   uses these yet — they unlock the batch allocation and bulk-book
   flows in follow-up PRs.

Tests: 98 unit tests pass across the touched paths (match-invoice,
match-supplier-invoice, supplier-voucher-matching, link-to-voucher).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(supplier-invoices): PR review — atomic link RPC, computeRemaining edge case, pg-real tests

Addresses the three real issues raised by Greptile on PR #602.

1. (P1) Atomic supplier voucher linking — new
   link_supplier_invoice_to_voucher PL/pgSQL RPC. The TS-side
   linkSupplierInvoiceToVoucher() previously did UPDATE-then-INSERT with
   a manual unconditional rollback. Under concurrent linking against the
   same invoice, request A's rollback could overwrite a sibling B's
   successful write while leaving B's payment row in place. Moving both
   writes into a single PG transaction (one RPC call) lets PG's own
   rollback handle the failure path correctly. TS wrapper now just
   translates the structured RPC return into the lib's Result type.

2. (P1) pg-real tests — tests/pg/transaction_voucher_links.pg.test.ts.
   CLAUDE.md mandates *.pg.test.ts for any PR adding a trigger, RPC, or
   RLS. The Phase 1A foundation migration added all three but had no
   pg-real coverage. Tests now cover:
     - trg_block_contradictory_invoice_denorm refusing contradictory
       UPDATEs on invoice_id and supplier_invoice_id
     - the same trigger PERMITTING a matching UPDATE (no false positives)
     - is_transaction_booked() returning true via journal_entry_id, via
       invoice_payments, and via transaction_voucher_links rows.

3. (P2) computeRemaining edge case — trust remaining_amount whenever
   the column is non-null (including the legitimate 0 for fully-paid
   invoices). The old "> 0" guard fell through to total - paid_amount,
   which under rounding drift could compute a tiny positive residue and
   slip a fully-paid invoice past LINK_SI_VOUCHER_INVOICE_FULLY_PAID.

The fourth Greptile comment (overdue invoices silently get no
candidates) was a misread: 'overdue' IS in the open-state list at
route.ts:35. No code change needed there.

Tests: 100 unit tests pass (16 in the directly-touched paths).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(supplier-invoices): PR review round 2 — broaden AP range, log event failures

Addresses the actionable findings from the compliance-swarm and
Swedish-accounting-compliance bot reviews on PR #602.

1. (swedish-accounting-compliance, high) AP account hardcoded to 2440
   rejected legitimate samlingsverifikationer that debit 2441
   (Leverantörsskulder i utländsk valuta), 2443 (Skuldfakturor), etc.
   BAS 2026 reserves the full 2440–2449 range for Leverantörsskulder.
   The TS-side AP_ACCOUNT constant becomes AP_ACCOUNT_PREFIX ('244')
   used with .like() and .startsWith(). The PL/pgSQL RPC's
   account_number filter becomes LIKE '244%'. The
   LINK_SI_VOUCHER_NO_AP_DEBIT error message updates to reference the
   244x range with examples.

2. (ISO 27001:2022 A.8.15 / OWASP V16) Empty catch on the
   supplier_invoice.paid event emission now logs with log.warn so a
   failure in the downstream reminder/audit subscriber leaves an
   auditable trail without blocking the response.

3. (GDPR Art.5(1)(c)) Documented design rationale for retaining
   select('*') on the post-link invoice re-fetch: the
   supplier_invoice.paid event payload is typed as
   `supplierInvoice: SupplierInvoice` in lib/events/types.ts, narrowing
   would break the subscriber contract. The event stays in-process
   and consumers legitimately need the full context.

Skipped findings:
  - V8.2.1 ownership concerns: route + RPC already filter by
    company_id from withRouteContext; the RPC's WHERE clause covers it.
  - DELETE policy scoping: matches the gnubok pattern across all
    company-scoped tables — any member with write access manages records.
  - transaction_id = NULL on the voucher-link path: by design — the
    flow has no bank tx (the voucher's 1930 line represents it).
  - Reverse-charge VAT (2614/2647) validation on linked vouchers:
    real concern but invasive change; tracked for follow-up.
  - Storno-chain integrity (linking the original of a storno pair):
    edge case; tracked for follow-up.

Tests: 26 unit tests pass in the directly-touched paths. RPC patch
applied to remote via Supabase MCP.

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 12:31:19 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent a586cc8a58
commit 7bcd46d503
19 changed files with 2510 additions and 44 deletions
@@ -0,0 +1,185 @@
-- Phase 1A — Foundation for multi-tx ↔ multi-voucher matching.
--
-- This migration scaffolds three pieces of schema that later phases (the
-- match_batch_allocate and bulk_book_transactions RPCs, the new transactions
-- inbox UI) build on. No RPC is added here — RPCs land in a follow-up
-- migration to keep review small.
--
-- 1. transaction_voucher_links
-- Junction table for N-tx → 1-JE flows (samlingsverifikation, bulk-book,
-- "link N bank lines to an existing day-summary verifikat"). The 1:1
-- case continues to use transactions.journal_entry_id; this junction is
-- additive. Sum(allocated_amount) per JE must match the JE's net 19xx
-- side within rounding tolerance — enforced by RPC business logic, not
-- a DB constraint (a partial allocation is legitimate before the second
-- bank line lands).
--
-- 2. block_contradictory_invoice_denorm trigger
-- transactions.invoice_id / supplier_invoice_id are denormalized pointers
-- that only carry meaning for the 1:1 case. After multi-match,
-- invoice_payments / supplier_invoice_payments are the source of truth.
-- This trigger refuses to set the denorm column to an invoice id that
-- already conflicts with a payment row, preventing the table from
-- silently lying after a multi-match.
--
-- 3. is_transaction_booked(uuid) SQL helper
-- Single source of truth for "is this tx anchored to a verifikat?". A tx
-- is booked when (a) transactions.journal_entry_id is set, or (b) any
-- invoice_payments row references it, or (c) any
-- supplier_invoice_payments row references it, or (d) any
-- transaction_voucher_links row references it. Used by inbox filters
-- and MCP list_uncategorized_transactions so the predicate stays
-- consistent across surfaces.
-- ─────────────────────────────────────────────────────────────────
-- 1. transaction_voucher_links: N-tx → 1-JE junction
-- ─────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.transaction_voucher_links (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES auth.users ON DELETE CASCADE,
company_id UUID NOT NULL REFERENCES public.companies ON DELETE CASCADE,
transaction_id UUID NOT NULL REFERENCES public.transactions ON DELETE CASCADE,
-- ON DELETE CASCADE so delete_last_voucher (which permits removing the last
-- draft / final voucher in a series, see 20260509103736 + 20260528120000)
-- transparently strips the link rows. The txs themselves remain but become
-- "unbooked" via is_transaction_booked() and re-surface in the inbox for
-- re-booking — the desired behaviour after voucher deletion.
journal_entry_id UUID NOT NULL REFERENCES public.journal_entries ON DELETE CASCADE,
-- Signed amount in the transaction's own currency. Positive when the tx
-- credits the JE's 19xx side (deposit), negative for debits (payment).
-- Sum across all rows pointing at a given JE must equal the JE's net 19xx
-- side within rounding tolerance — enforced by RPC business logic.
allocated_amount NUMERIC(15,2) NOT NULL,
-- bank_line = ordinary settlement leg (default)
-- clearing = clearing-account leg (e.g. card/Swish day-summary clearing)
-- other = future use
role TEXT NOT NULL DEFAULT 'bank_line',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT transaction_voucher_links_role_check
CHECK (role IN ('bank_line', 'clearing', 'other')),
CONSTRAINT transaction_voucher_links_tx_je_unique
UNIQUE (transaction_id, journal_entry_id)
);
CREATE INDEX IF NOT EXISTS idx_transaction_voucher_links_company_id
ON public.transaction_voucher_links (company_id);
CREATE INDEX IF NOT EXISTS idx_transaction_voucher_links_transaction_id
ON public.transaction_voucher_links (transaction_id);
CREATE INDEX IF NOT EXISTS idx_transaction_voucher_links_journal_entry_id
ON public.transaction_voucher_links (journal_entry_id);
ALTER TABLE public.transaction_voucher_links ENABLE ROW LEVEL SECURITY;
CREATE POLICY "transaction_voucher_links_select" ON public.transaction_voucher_links
FOR SELECT USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "transaction_voucher_links_insert" ON public.transaction_voucher_links
FOR INSERT WITH CHECK (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "transaction_voucher_links_update" ON public.transaction_voucher_links
FOR UPDATE USING (company_id IN (SELECT public.user_company_ids()))
WITH CHECK (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "transaction_voucher_links_delete" ON public.transaction_voucher_links
FOR DELETE USING (company_id IN (SELECT public.user_company_ids()));
CREATE TRIGGER transaction_voucher_links_updated_at
BEFORE UPDATE ON public.transaction_voucher_links
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
COMMENT ON TABLE public.transaction_voucher_links IS
'N-tx → 1-JE junction. Use when one verifikat aggregates multiple bank lines (samlingsverifikation, bulk-book). The 1:1 case continues to use transactions.journal_entry_id; this junction is additive. is_transaction_booked() consults both.';
-- ─────────────────────────────────────────────────────────────────
-- 2. block_contradictory_invoice_denorm
-- ─────────────────────────────────────────────────────────────────
CREATE OR REPLACE FUNCTION public.block_contradictory_invoice_denorm()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
DECLARE
conflicting_invoice_id UUID;
conflicting_supplier_invoice_id UUID;
BEGIN
-- transactions.invoice_id must not contradict the invoice_payments table.
IF NEW.invoice_id IS NOT NULL THEN
SELECT ip.invoice_id INTO conflicting_invoice_id
FROM public.invoice_payments ip
WHERE ip.transaction_id = NEW.id
AND ip.invoice_id <> NEW.invoice_id
LIMIT 1;
IF conflicting_invoice_id IS NOT NULL THEN
RAISE EXCEPTION
'transactions.invoice_id=% contradicts invoice_payments(invoice_id=%) for tx %',
NEW.invoice_id, conflicting_invoice_id, NEW.id
USING ERRCODE = 'check_violation';
END IF;
END IF;
-- Same for supplier_invoice_id.
IF NEW.supplier_invoice_id IS NOT NULL THEN
SELECT sip.supplier_invoice_id INTO conflicting_supplier_invoice_id
FROM public.supplier_invoice_payments sip
WHERE sip.transaction_id = NEW.id
AND sip.supplier_invoice_id <> NEW.supplier_invoice_id
LIMIT 1;
IF conflicting_supplier_invoice_id IS NOT NULL THEN
RAISE EXCEPTION
'transactions.supplier_invoice_id=% contradicts supplier_invoice_payments(supplier_invoice_id=%) for tx %',
NEW.supplier_invoice_id, conflicting_supplier_invoice_id, NEW.id
USING ERRCODE = 'check_violation';
END IF;
END IF;
RETURN NEW;
END;
$$;
-- BEFORE INSERT OR UPDATE so an INSERT carrying both invoice_id and a pre-
-- existing payment row (unusual but possible via direct DB insert) also gets
-- caught. The UPDATE path is the common one (the match endpoints set
-- invoice_id after inserting the payment row).
CREATE TRIGGER trg_block_contradictory_invoice_denorm
BEFORE INSERT OR UPDATE OF invoice_id, supplier_invoice_id
ON public.transactions
FOR EACH ROW EXECUTE FUNCTION public.block_contradictory_invoice_denorm();
COMMENT ON COLUMN public.transactions.invoice_id IS
'Denormalized link for the 1:1 tx → invoice case. NULL when the tx settles multiple invoices (truth lives in invoice_payments). Guarded by trg_block_contradictory_invoice_denorm.';
COMMENT ON COLUMN public.transactions.supplier_invoice_id IS
'Denormalized link for the 1:1 tx → supplier_invoice case. NULL when the tx settles multiple supplier invoices (truth lives in supplier_invoice_payments). Guarded by trg_block_contradictory_invoice_denorm.';
-- ─────────────────────────────────────────────────────────────────
-- 3. is_transaction_booked(uuid)
-- ─────────────────────────────────────────────────────────────────
CREATE OR REPLACE FUNCTION public.is_transaction_booked(p_transaction_id UUID)
RETURNS BOOLEAN
LANGUAGE sql
STABLE
SECURITY INVOKER
AS $$
SELECT
EXISTS (
SELECT 1 FROM public.transactions t
WHERE t.id = p_transaction_id
AND t.journal_entry_id IS NOT NULL
)
OR EXISTS (
SELECT 1 FROM public.invoice_payments ip
WHERE ip.transaction_id = p_transaction_id
)
OR EXISTS (
SELECT 1 FROM public.supplier_invoice_payments sip
WHERE sip.transaction_id = p_transaction_id
)
OR EXISTS (
SELECT 1 FROM public.transaction_voucher_links tvl
WHERE tvl.transaction_id = p_transaction_id
);
$$;
COMMENT ON FUNCTION public.is_transaction_booked(UUID) IS
'Returns true if the transaction is anchored to ANY verifikat — directly via journal_entry_id, indirectly via a payment row, or via the transaction_voucher_links junction. Single source of truth for "is this booked?" used by inbox filters, reconciliation status, and MCP list_uncategorized_transactions.';
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,190 @@
-- PR #602 review fix — atomic supplier-invoice voucher linking RPC.
--
-- Closes the race surfaced by greptile review: the TS-side
-- linkSupplierInvoiceToVoucher() updated the supplier_invoices row first, then
-- inserted the supplier_invoice_payments row, with a manual unconditional
-- rollback on insert failure. Under concurrent linking against the same
-- invoice (A starts on `registered`, B completes to `paid`, A's insert fails
-- and the rollback overwrites B's `paid` back to `registered`) the rollback
-- could clobber a sibling's successful write while leaving its payment row
-- in place. This RPC moves both writes into a single Postgres transaction
-- so PG's own rollback handles the failure path correctly.
--
-- Mirrors the existing commit_journal_entry pattern (atomic voucher commit).
-- The TS wrapper now reads the validated invoice + voucher state from the
-- RPC return payload and only emits the `supplier_invoice.paid` event on the
-- happy path.
CREATE OR REPLACE FUNCTION public.link_supplier_invoice_to_voucher(
p_supplier_invoice_id uuid,
p_journal_entry_id uuid,
p_user_id uuid,
p_company_id uuid,
p_notes text DEFAULT NULL
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $$
DECLARE
v_invoice RECORD;
v_voucher RECORD;
v_ap_debit_total numeric := 0;
v_line_currency text;
v_remaining numeric;
v_payment_amount numeric;
v_new_paid numeric;
v_new_remaining numeric;
v_new_status text;
v_is_fully_paid boolean;
v_now timestamptz := now();
v_payment_id uuid;
BEGIN
-- 1. Lock invoice for the duration of this transaction. FOR UPDATE so a
-- concurrent linker has to wait until we commit (or roll back).
SELECT * INTO v_invoice
FROM public.supplier_invoices
WHERE id = p_supplier_invoice_id AND company_id = p_company_id
FOR UPDATE;
IF NOT FOUND THEN
RETURN jsonb_build_object('ok', false, 'code', 'LINK_SI_VOUCHER_INVOICE_NOT_FOUND');
END IF;
IF v_invoice.status NOT IN ('registered', 'approved', 'overdue', 'partially_paid') THEN
RETURN jsonb_build_object(
'ok', false,
'code', 'LINK_SI_VOUCHER_INVOICE_FULLY_PAID',
'details', jsonb_build_object('status', v_invoice.status)
);
END IF;
-- Trust the stored remaining_amount when present (even when 0), only fall
-- through to total - paid_amount when the column is NULL. The "> 0" guard
-- was the original sin from voucher-matching.ts; rounding drift on a
-- fully-paid invoice persisted as remaining_amount=0 could compute a
-- residual via total - paid_amount and slip past the FULLY_PAID guard.
v_remaining := COALESCE(v_invoice.remaining_amount,
v_invoice.total - COALESCE(v_invoice.paid_amount, 0));
IF v_remaining <= 0.005 THEN
RETURN jsonb_build_object('ok', false, 'code', 'LINK_SI_VOUCHER_INVOICE_FULLY_PAID');
END IF;
-- 2. Resolve the voucher
SELECT * INTO v_voucher
FROM public.journal_entries
WHERE id = p_journal_entry_id AND company_id = p_company_id;
IF NOT FOUND THEN
RETURN jsonb_build_object('ok', false, 'code', 'LINK_SI_VOUCHER_VOUCHER_NOT_FOUND');
END IF;
IF v_voucher.status <> 'posted' THEN
RETURN jsonb_build_object(
'ok', false,
'code', 'LINK_SI_VOUCHER_NOT_POSTED',
'details', jsonb_build_object('status', v_voucher.status)
);
END IF;
IF v_voucher.source_type IN ('opening_balance', 'storno') THEN
RETURN jsonb_build_object(
'ok', false,
'code', 'LINK_SI_VOUCHER_NO_AP_DEBIT',
'details', jsonb_build_object('source_type', v_voucher.source_type)
);
END IF;
-- 3. Sum AP debit on 2440 across all lines in this voucher.
SELECT COALESCE(SUM(debit_amount), 0), MAX(currency)
INTO v_ap_debit_total, v_line_currency
FROM public.journal_entry_lines
WHERE journal_entry_id = p_journal_entry_id
AND account_number = '2440'
AND debit_amount > 0;
v_ap_debit_total := ROUND(v_ap_debit_total * 100) / 100;
IF v_ap_debit_total <= 0 THEN
RETURN jsonb_build_object('ok', false, 'code', 'LINK_SI_VOUCHER_NO_AP_DEBIT');
END IF;
IF COALESCE(v_line_currency, v_invoice.currency) IS DISTINCT FROM v_invoice.currency THEN
RETURN jsonb_build_object(
'ok', false,
'code', 'LINK_SI_VOUCHER_CURRENCY_MISMATCH',
'details', jsonb_build_object(
'invoice_currency', v_invoice.currency,
'line_currency', v_line_currency
)
);
END IF;
IF v_ap_debit_total > v_remaining + 0.005 THEN
RETURN jsonb_build_object(
'ok', false,
'code', 'LINK_SI_VOUCHER_AMOUNT_EXCEEDS_REMAINING',
'details', jsonb_build_object(
'ap_debit', v_ap_debit_total,
'remaining', ROUND(v_remaining * 100) / 100
)
);
END IF;
-- 4. Reject re-link of the same voucher to the same invoice.
IF EXISTS (
SELECT 1 FROM public.supplier_invoice_payments
WHERE company_id = p_company_id
AND supplier_invoice_id = p_supplier_invoice_id
AND journal_entry_id = p_journal_entry_id
) THEN
RETURN jsonb_build_object('ok', false, 'code', 'LINK_SI_VOUCHER_ALREADY_LINKED');
END IF;
-- 5. Compute the advance.
v_payment_amount := LEAST(v_ap_debit_total, ROUND(v_remaining * 100) / 100);
v_new_remaining := GREATEST(0,
ROUND((v_remaining - v_payment_amount) * 100) / 100
);
v_new_paid := ROUND((COALESCE(v_invoice.paid_amount, 0) + v_payment_amount) * 100) / 100;
v_is_fully_paid := v_new_remaining <= 0.005;
v_new_status := CASE WHEN v_is_fully_paid THEN 'paid' ELSE 'partially_paid' END;
-- 6. Apply both writes. The RPC body is one transaction; a failure on the
-- INSERT triggers PG's own rollback of the UPDATE — no manual rollback
-- path needed.
UPDATE public.supplier_invoices
SET status = v_new_status,
paid_at = CASE WHEN v_is_fully_paid THEN v_now ELSE paid_at END,
paid_amount = v_new_paid,
remaining_amount = v_new_remaining,
updated_at = v_now
WHERE id = p_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, notes
) VALUES (
p_user_id, p_company_id, p_supplier_invoice_id, v_voucher.entry_date,
v_payment_amount, v_invoice.currency, p_journal_entry_id, NULL, p_notes
)
RETURNING id INTO v_payment_id;
RETURN jsonb_build_object(
'ok', true,
'payment_id', v_payment_id,
'invoice_status', v_new_status,
'paid_amount', v_new_paid,
'remaining_amount', v_new_remaining,
'payment_amount', v_payment_amount,
'journal_entry_id', p_journal_entry_id,
'currency', v_invoice.currency
);
END;
$$;
COMMENT ON FUNCTION public.link_supplier_invoice_to_voucher(uuid, uuid, uuid, uuid, text) IS
'Atomically link an existing posted verifikat as payment for a supplier invoice. Locks the invoice row, validates the voucher debits 2440, advances paid_amount/remaining_amount/status, and inserts a supplier_invoice_payments row in one PG transaction. Returns jsonb { ok, ..., payment_id } on success or { ok: false, code, details } on guard failure.';
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,144 @@
-- PR #602 Swedish-compliance fix — broaden AP-account check in
-- link_supplier_invoice_to_voucher to cover the full BAS 2440–2449 range
-- (2440 Leverantörsskulder, 2441 Leverantörsskulder i utländsk valuta,
-- 2443 Skuldfakturor leverantörer, etc.). A samlingsverifikat paying mixed
-- SEK + EUR suppliers will legitimately debit both 2440 and 2441; the
-- earlier hardcode rejected the latter with LINK_SI_VOUCHER_NO_AP_DEBIT
-- even though the booking was BAS-compliant.
--
-- Mirrors the TS-side change in lib/invoices/supplier-voucher-matching.ts
-- where AP_ACCOUNT='2440' became AP_ACCOUNT_PREFIX='244'.
CREATE OR REPLACE FUNCTION public.link_supplier_invoice_to_voucher(
p_supplier_invoice_id uuid,
p_journal_entry_id uuid,
p_user_id uuid,
p_company_id uuid,
p_notes text DEFAULT NULL
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $$
DECLARE
v_invoice RECORD;
v_voucher RECORD;
v_ap_debit_total numeric := 0;
v_line_currency text;
v_remaining numeric;
v_payment_amount numeric;
v_new_paid numeric;
v_new_remaining numeric;
v_new_status text;
v_is_fully_paid boolean;
v_now timestamptz := now();
v_payment_id uuid;
BEGIN
SELECT * INTO v_invoice
FROM public.supplier_invoices
WHERE id = p_supplier_invoice_id AND company_id = p_company_id
FOR UPDATE;
IF NOT FOUND THEN
RETURN jsonb_build_object('ok', false, 'code', 'LINK_SI_VOUCHER_INVOICE_NOT_FOUND');
END IF;
IF v_invoice.status NOT IN ('registered', 'approved', 'overdue', 'partially_paid') THEN
RETURN jsonb_build_object('ok', false, 'code', 'LINK_SI_VOUCHER_INVOICE_FULLY_PAID',
'details', jsonb_build_object('status', v_invoice.status));
END IF;
v_remaining := COALESCE(v_invoice.remaining_amount, v_invoice.total - COALESCE(v_invoice.paid_amount, 0));
IF v_remaining <= 0.005 THEN
RETURN jsonb_build_object('ok', false, 'code', 'LINK_SI_VOUCHER_INVOICE_FULLY_PAID');
END IF;
SELECT * INTO v_voucher
FROM public.journal_entries
WHERE id = p_journal_entry_id AND company_id = p_company_id;
IF NOT FOUND THEN
RETURN jsonb_build_object('ok', false, 'code', 'LINK_SI_VOUCHER_VOUCHER_NOT_FOUND');
END IF;
IF v_voucher.status <> 'posted' THEN
RETURN jsonb_build_object('ok', false, 'code', 'LINK_SI_VOUCHER_NOT_POSTED',
'details', jsonb_build_object('status', v_voucher.status));
END IF;
IF v_voucher.source_type IN ('opening_balance', 'storno') THEN
RETURN jsonb_build_object('ok', false, 'code', 'LINK_SI_VOUCHER_NO_AP_DEBIT',
'details', jsonb_build_object('source_type', v_voucher.source_type));
END IF;
-- Sum AP debit across the full 244x range (was: account_number = '2440').
SELECT COALESCE(SUM(debit_amount), 0), MAX(currency)
INTO v_ap_debit_total, v_line_currency
FROM public.journal_entry_lines
WHERE journal_entry_id = p_journal_entry_id
AND account_number LIKE '244%'
AND debit_amount > 0;
v_ap_debit_total := ROUND(v_ap_debit_total * 100) / 100;
IF v_ap_debit_total <= 0 THEN
RETURN jsonb_build_object('ok', false, 'code', 'LINK_SI_VOUCHER_NO_AP_DEBIT');
END IF;
IF COALESCE(v_line_currency, v_invoice.currency) IS DISTINCT FROM v_invoice.currency THEN
RETURN jsonb_build_object('ok', false, 'code', 'LINK_SI_VOUCHER_CURRENCY_MISMATCH',
'details', jsonb_build_object('invoice_currency', v_invoice.currency, 'line_currency', v_line_currency));
END IF;
IF v_ap_debit_total > v_remaining + 0.005 THEN
RETURN jsonb_build_object('ok', false, 'code', 'LINK_SI_VOUCHER_AMOUNT_EXCEEDS_REMAINING',
'details', jsonb_build_object('ap_debit', v_ap_debit_total, 'remaining', ROUND(v_remaining * 100) / 100));
END IF;
IF EXISTS (
SELECT 1 FROM public.supplier_invoice_payments
WHERE company_id = p_company_id
AND supplier_invoice_id = p_supplier_invoice_id
AND journal_entry_id = p_journal_entry_id
) THEN
RETURN jsonb_build_object('ok', false, 'code', 'LINK_SI_VOUCHER_ALREADY_LINKED');
END IF;
v_payment_amount := LEAST(v_ap_debit_total, ROUND(v_remaining * 100) / 100);
v_new_remaining := GREATEST(0, ROUND((v_remaining - v_payment_amount) * 100) / 100);
v_new_paid := ROUND((COALESCE(v_invoice.paid_amount, 0) + v_payment_amount) * 100) / 100;
v_is_fully_paid := v_new_remaining <= 0.005;
v_new_status := CASE WHEN v_is_fully_paid THEN 'paid' ELSE 'partially_paid' END;
UPDATE public.supplier_invoices
SET status = v_new_status,
paid_at = CASE WHEN v_is_fully_paid THEN v_now ELSE paid_at END,
paid_amount = v_new_paid,
remaining_amount = v_new_remaining,
updated_at = v_now
WHERE id = p_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, notes
) VALUES (
p_user_id, p_company_id, p_supplier_invoice_id, v_voucher.entry_date,
v_payment_amount, v_invoice.currency, p_journal_entry_id, NULL, p_notes
)
RETURNING id INTO v_payment_id;
RETURN jsonb_build_object(
'ok', true,
'payment_id', v_payment_id,
'invoice_status', v_new_status,
'paid_amount', v_new_paid,
'remaining_amount', v_new_remaining,
'payment_amount', v_payment_amount,
'journal_entry_id', p_journal_entry_id,
'currency', v_invoice.currency
);
END;
$$;
NOTIFY pgrst, 'reload schema';