Files
accounted/supabase/migrations/20260830140000_link_voucher_rpcs_fx_residual_settlement.sql
Mattsson 7162053e89 fix(bookkeeping): link RPCs settle cross-currency invoices with 7960/3960 FX residual (#2037)
* fix(bookkeeping): link RPCs settle cross-currency invoices with 7960/3960 FX residual

A foreign-currency invoice whose receivable (1510) or payable (2440) was
booked in plain SEK could not be settled by any API path:
link_invoice_to_voucher and link_supplier_invoice_to_voucher failed closed
with LINK_VOUCHER_CURRENCY_MISMATCH on every SEK-booked matched-side line.

Port match_batch_allocate's cross-currency settlement into both RPCs, with
identical sign conventions: when every matched-side line is genuinely
SEK-booked, the invoice has a sane exchange_rate, and the voucher's SEK sum
is within 10 percent of remaining * rate, the voucher settles the full
remaining and the FX residual (booked_sek - settled_sek) is booked to 7960
(loss) / 3960 (gain). Because the linked voucher is posted and immutable,
the residual lives in its own balanced two-line verifikat committed through
commit_journal_entry, dated on the voucher's entry_date with an explicit
open-period check. Every ambiguous case (mixed readable/SEK lines, third
currency label, missing rate, kontantmetoden, deviation outside the band,
locked period) keeps the existing mismatch codes, now with details.reason.

Verified with a 14-scenario transactional probe against staging Postgres
(rolled back; catalog untouched) plus tests/pg/link-voucher-fx-residual
.pg.test.ts, which applies the migration inside each test's transaction so
it runs against a database that has not applied it yet.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zGah8Yy49esAwpnKGxiGy

* fix(bookkeeping): move FX residual migration past 20260830101500 from main

Main gained 20260830101500_seed_agent_atom_bodies.sql, a later version than
this branch's 20260830100000; renamed to 20260830120000 so the migration
chain stays ordered. Test and decision-log references updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zGah8Yy49esAwpnKGxiGy

* fix(bookkeeping): gate the FX fallback on readable line count, not sum

Skeptic counterexample: a matched-side line labelled with the invoice's
currency but carrying amount_in_currency = 0 is a readable LINE that sums
to 0. The sum-based gate engaged the fallback while the line's real SEK
ledger movement was excluded from the settled sum, over-crediting the
receivable (mirrored on AP) and booking a phantom FX result. The gate now
counts readable lines: any readable line disables the fallback, so the SEK
sum is provably the full matched-side ledger amount whenever it engages.

Verified against staging Postgres in a rolled-back transaction: both
counterexample vouchers now refuse with LINK_*_CURRENCY_MISMATCH and no
writes, while the plain-SEK settlement paths still book balanced 7960/3960
residuals. Regression tests added for both sides.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zGah8Yy49esAwpnKGxiGy

* fix(bookkeeping): move FX residual migration past colliding 20260830120000 from main

Main gained 20260830120000_reminder_text_overrides.sql, colliding with this
branch's version timestamp; renamed to 20260830140000 (references updated).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zGah8Yy49esAwpnKGxiGy

* fix(invoices): stage-time validators mirror the RPC's SEK settlement gate

The MCP staging path pre-validates links with validateVoucherForInvoiceLink
and validateVoucherForSupplierInvoiceLink before the RPC ever runs, so the
new FX residual fallback was unreachable through MCP: the exact case this
change exists for. Both validators now mirror the RPC's gate byte-for-byte
(migration 20260830140000): accrual only on the customer side, zero
readable lines counted per LINE (a zero-amount readable line disables the
fallback), every unreadable matched-side line SEK-booked, sane
exchange_rate bounds, and the 10 percent deviation band; eligible vouchers
validate as a full-remaining settlement, everything else keeps the
CURRENCY_MISMATCH refusal with details.reason.

Unit tests: fallback settlement, deviation refusal, zero-amount readable
line refusal, kontantmetoden refusal, missing-rate refusal, and supplier
mirrors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zGah8Yy49esAwpnKGxiGy

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 16:54:35 +02:00

892 lines
40 KiB
PL/PgSQL

-- Cross-currency voucher linking: settle a SEK-booked payment voucher against
-- a foreign-currency invoice by booking the FX residual to 7960/3960, instead
-- of refusing with LINK_VOUCHER_CURRENCY_MISMATCH / LINK_SI_VOUCHER_CURRENCY_MISMATCH.
--
-- THE GAP: 20260726140000 taught both link RPCs to resolve the matched amount
-- in the INVOICE's currency. On a foreign invoice the only readable column is
-- `amount_in_currency` on lines labelled with that currency; any matched-side
-- line without such a figure made the voucher UNREADABLE and the RPC failed
-- closed. That is the right call for a line stamped with a THIRD currency,
-- but it also rejected the most common real-world shape: a EUR invoice whose
-- receivable was booked in kronor on 1510 (SIE import, manual voucher, older
-- engine output) and whose payment voucher is plain SEK with no currency
-- metadata at all. Such an invoice could not be settled by any API path.
--
-- THE FIX ports the cross-currency settlement that match_batch_allocate
-- (20260824120000) already performs, with the SAME sign conventions:
--
-- v_fx_diff = booked_sek - settled_sek, both rounded to the öre, where
-- booked_sek = ROUND(remaining * invoices.exchange_rate * 100) / 100 and
-- settled_sek is the voucher's matched-side SEK ledger sum.
--
-- Customer: diff > 0 (received less than booked) -> Dr 7960 Valutakursförlust
-- diff < 0 (received more than booked) -> Cr 3960 Valutakursvinst
-- Supplier: diff > 0 (paid less than booked) -> Cr 3960 Valutakursvinst
-- diff < 0 (paid more than booked) -> Dr 7960 Valutakursförlust
--
-- One structural difference from match_batch_allocate: that RPC CREATES its
-- settlement verifikat, so the residual line lives inside it. The link RPCs
-- link an EXISTING posted voucher, which is immutable (BFL 5 kap 5 §,
-- enforcement triggers from migration 017). The residual is therefore booked
-- as its OWN two-line SEK verifikat (committed atomically through
-- commit_journal_entry, so voucher numbering stays sequential), with the
-- AR/AP counter-leg on the same account the voucher settled. Combined,
-- invoice entry + payment voucher + residual verifikat net the receivable /
-- payable to exactly zero: identical ledger effect to the single verifikat
-- match_batch_allocate books. The residual is öre-rounded on both inputs, so
-- it is either exactly 0.00 (no verifikat needed) or >= 0.01 (a balanced
-- debit/credit pair, both sides > 0).
--
-- The fallback engages ONLY when every part of the case is unambiguous:
-- * the invoice is foreign (resolved currency <> 'SEK');
-- * NO matched-side line is readable in the invoice currency, counted by
-- LINE, not by sum: a line labelled with the invoice's currency that
-- carries amount_in_currency = 0 is still a readable line, and it must
-- disable the fallback (its real SEK ledger movement is excluded from
-- the SEK sum, so engaging anyway would understate the settlement). A
-- mixed voucher stays fail-closed: summing units would be guesswork;
-- * every unreadable matched-side line is genuinely SEK-booked, i.e. its
-- label is 'SEK' or NULL. A line labelled with the invoice's currency but
-- missing amount_in_currency is malformed metadata (buildCurrencyMetadata
-- stamps label and figure together), and a third-currency label is a
-- counterparty discriminator: both keep today's mismatch error;
-- * customer side: accounting method is accrual. On kontantmetoden no
-- receivable was ever booked, so there is no residual to true up and no
-- account to book it against;
-- * invoices/supplier_invoices.exchange_rate is present and sane (same
-- bounds as match_batch_allocate: > 0, < 100000);
-- * the voucher's SEK total is within 10% of booked_sek (same deviation
-- band as match_batch_allocate). The voucher is then read as settling the
-- FULL remaining, exactly as match_batch_allocate treats cross-currency
-- allocations; a partial SEK settlement of a foreign invoice falls
-- outside the band and stays fail-closed.
--
-- Every fail-closed branch keeps the existing CURRENCY_MISMATCH codes (with a
-- `reason` in details for diagnosability), so no TS error mapping changes and
-- callers see the same stable codes as before.
--
-- The payment row records the FULL remaining in the invoice's currency plus
-- payment_exchange_rate = settled_sek / remaining (round-6), mirroring
-- match_batch_allocate's traceability convention. The residual verifikat is
-- created only AFTER the already-linked guard, and the whole RPC body is one
-- transaction: a later failure rolls the verifikat back.
--
-- Everything else in both bodies is verbatim from 20260801204551
-- (UTC-noon paid_at projection + NULL-safe caller_is_company_member guard).
--
-- No schema change, no trigger touched: two CREATE OR REPLACE FUNCTION bodies.
-- pg-test: tests/pg/link-voucher-fx-residual.pg.test.ts
CREATE OR REPLACE FUNCTION public.link_invoice_to_voucher(
p_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_ar_credit_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;
v_jwt_role text := coalesce(nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'role', '');
v_acting_user uuid := p_user_id;
v_accounting_method text;
-- Unit resolution (20260726140000): the currency the invoice's amounts are
-- quoted in, plus the matched-side lines that cannot be expressed in it.
v_invoice_currency text;
v_account_prefix text;
v_unreadable_count integer := 0;
v_unreadable_currency text;
-- FX residual settlement (new): a foreign invoice whose matched side is
-- booked plain SEK. See the header comment.
v_readable_count integer := 0;
v_sek_side_total numeric := 0;
v_foreign_label_count integer := 0;
v_booked_sek numeric;
v_fx_diff numeric := 0;
v_fx_settled boolean := false;
v_payment_rate numeric; -- round-6 effective rate (traceability)
v_fx_account text;
v_fx_entry_id uuid;
v_fx_voucher_number int;
v_fiscal_period_id uuid;
v_period_is_closed boolean;
v_period_locked_at timestamptz;
v_inv_number_short text;
BEGIN
-- 0. Tenant guard (mirrors 20260611140000): anon/authenticated may only act
-- on their own companies; service_role / direct access bypasses. The
-- NULL-safe caller_is_company_member() form (20260703180000): the raw
-- membership-subquery shape skips the deny branch on UNKNOWN and is
-- banned by the pg-real ratchet (tests/pg/null-safe-tenant-guards
-- .pg.test.ts, which scans prosrc, comments included).
IF v_jwt_role IN ('anon', 'authenticated') THEN
IF NOT public.caller_is_company_member(p_company_id) THEN
RETURN jsonb_build_object('ok', false, 'code', 'LINK_VOUCHER_INVOICE_NOT_FOUND');
END IF;
-- Attribution: the JWT sub is authoritative for user-session callers:
-- p_user_id cannot point the payment row at someone else.
v_acting_user := coalesce(
(nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'sub')::uuid,
p_user_id
);
END IF;
IF p_notes IS NOT NULL AND char_length(p_notes) > 2000 THEN
RETURN jsonb_build_object(
'ok', false,
'code', 'LINK_VOUCHER_NOTES_TOO_LONG',
'details', jsonb_build_object('max_length', 2000, 'length', char_length(p_notes))
);
END IF;
-- 1. Lock the 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.invoices
WHERE id = p_invoice_id AND company_id = p_company_id
FOR UPDATE;
IF NOT FOUND THEN
RETURN jsonb_build_object('ok', false, 'code', 'LINK_VOUCHER_INVOICE_NOT_FOUND');
END IF;
IF v_invoice.status NOT IN ('sent', 'overdue', 'partially_paid') THEN
RETURN jsonb_build_object(
'ok', false,
'code', 'LINK_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_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_VOUCHER_VOUCHER_NOT_FOUND');
END IF;
IF v_voucher.status <> 'posted' THEN
RETURN jsonb_build_object(
'ok', false,
'code', 'LINK_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_VOUCHER_NO_AR_CREDIT',
'details', jsonb_build_object('source_type', v_voucher.source_type)
);
END IF;
-- 3. Sum the matched amount across the voucher's lines, EXPRESSED IN THE
-- INVOICE'S CURRENCY. Branch on the company's accounting method (defaults
-- to accrual when no settings row).
SELECT cs.accounting_method INTO v_accounting_method
FROM public.company_settings cs
WHERE cs.company_id = p_company_id;
v_accounting_method := COALESCE(v_accounting_method, 'accrual');
-- `invoices.currency` is `text default 'SEK'` and therefore NULLABLE; a
-- missing code has always meant kronor, and must not be read as "not SEK".
v_invoice_currency := COALESCE(v_invoice.currency, 'SEK');
v_account_prefix := CASE WHEN v_accounting_method = 'cash' THEN '19' ELSE '151' END;
IF v_invoice_currency = 'SEK' THEN
-- VERBATIM from 20260620130000. The ledger columns are kronor already, so
-- the document label on the line is irrelevant here.
IF v_accounting_method = 'cash' THEN
-- Kontantmetoden: the payment verifikat debits a liquid-funds account (19xx).
SELECT COALESCE(SUM(debit_amount), 0), MAX(currency)
INTO v_ar_credit_total, v_line_currency
FROM public.journal_entry_lines
WHERE journal_entry_id = p_journal_entry_id
AND account_number LIKE '19%'
AND debit_amount > 0;
ELSE
-- Faktureringsmetoden: the payment verifikat credits the AR account (151x).
SELECT COALESCE(SUM(credit_amount), 0), MAX(currency)
INTO v_ar_credit_total, v_line_currency
FROM public.journal_entry_lines
WHERE journal_entry_id = p_journal_entry_id
AND account_number LIKE '151%'
AND credit_amount > 0;
END IF;
ELSE
-- Foreign invoice: `amount_in_currency` is the only column quoted in the
-- invoice's currency. Magnitude from ABS() because a handful of production
-- rows store the foreign figure negatively while the debit/credit side is
-- authoritative, and that side is already pinned by the `> 0` predicate.
--
-- Three additional aggregates (new) feed the FX residual fallback:
-- * how many matched-side lines are readable at all: the gate below
-- must count LINES, not test the sum, because a readable line with
-- amount_in_currency = 0 sums to 0 while its SEK ledger movement is
-- real and excluded from the SEK sum: engaging the fallback over it
-- would understate the settlement and fabricate an FX residual;
-- * the matched side's raw SEK ledger sum over SEK-booked lines
-- (label 'SEK' or NULL);
-- * how many unreadable lines are NOT SEK-booked (third-currency label,
-- or the invoice's label without a figure): any such line keeps the
-- fallback disabled.
SELECT
COALESCE(SUM(ABS(l.amount_in_currency)) FILTER (
WHERE l.currency = v_invoice_currency AND l.amount_in_currency IS NOT NULL
), 0),
MAX(l.currency) FILTER (
WHERE l.currency = v_invoice_currency AND l.amount_in_currency IS NOT NULL
),
COUNT(*) FILTER (
WHERE l.currency IS DISTINCT FROM v_invoice_currency OR l.amount_in_currency IS NULL
),
MIN(l.currency) FILTER (
WHERE l.currency IS DISTINCT FROM v_invoice_currency OR l.amount_in_currency IS NULL
),
COUNT(*) FILTER (
WHERE l.currency = v_invoice_currency AND l.amount_in_currency IS NOT NULL
),
COALESCE(SUM(CASE WHEN v_accounting_method = 'cash' THEN l.debit_amount ELSE l.credit_amount END) FILTER (
WHERE COALESCE(l.currency, 'SEK') = 'SEK'
), 0),
COUNT(*) FILTER (
WHERE (l.currency IS DISTINCT FROM v_invoice_currency OR l.amount_in_currency IS NULL)
AND COALESCE(l.currency, 'SEK') <> 'SEK'
)
INTO v_ar_credit_total, v_line_currency, v_unreadable_count, v_unreadable_currency,
v_readable_count, v_sek_side_total, v_foreign_label_count
FROM public.journal_entry_lines l
WHERE l.journal_entry_id = p_journal_entry_id
AND l.account_number LIKE v_account_prefix || '%'
AND (CASE WHEN v_accounting_method = 'cash' THEN l.debit_amount ELSE l.credit_amount END) > 0;
IF COALESCE(v_unreadable_count, 0) > 0 THEN
-- FX residual fallback (new): the voucher settles the invoice in plain
-- kronor. Engage only in the unambiguous case; see the header comment.
-- The readable gate counts LINES (see the aggregate comment): with
-- zero readable lines, v_sek_side_total is provably the FULL
-- matched-side ledger sum and v_line_currency is NULL by construction.
IF v_accounting_method = 'accrual'
AND COALESCE(v_readable_count, 0) = 0
AND COALESCE(v_foreign_label_count, 0) = 0
AND v_sek_side_total > 0
AND v_invoice.exchange_rate IS NOT NULL
AND v_invoice.exchange_rate > 0
AND v_invoice.exchange_rate < 100000
THEN
v_sek_side_total := ROUND(v_sek_side_total * 100) / 100;
v_booked_sek := ROUND(v_remaining * v_invoice.exchange_rate * 100) / 100;
-- Same 10% deviation band as match_batch_allocate: outside it the
-- voucher is simply the wrong voucher (e.g. 1 000 kr against a
-- 1 000 EUR remainder), not an FX difference.
IF ABS(v_sek_side_total - v_booked_sek) > v_booked_sek * 0.10 THEN
RETURN jsonb_build_object(
'ok', false,
'code', 'LINK_VOUCHER_CURRENCY_MISMATCH',
'details', jsonb_build_object(
'invoice_currency', v_invoice.currency,
'line_currency', v_unreadable_currency,
'reason', 'fx_deviation_too_large',
'expected_sek', v_booked_sek,
'voucher_sek', v_sek_side_total
)
);
END IF;
v_fx_settled := true;
v_fx_diff := ROUND((v_booked_sek - v_sek_side_total) * 100) / 100;
-- The voucher settles the FULL remaining (match_batch_allocate's
-- cross-currency convention); the residual verifikat below trues up
-- the receivable. v_line_currency is NULL here (no readable line),
-- so the label guard further down passes by COALESCE.
v_ar_credit_total := ROUND(v_remaining * 100) / 100;
v_payment_rate := ROUND((v_sek_side_total / v_remaining) * 1000000) / 1000000;
ELSE
-- Fail CLOSED, exactly as before, on everything the fallback cannot
-- read unambiguously: mixed readable/SEK vouchers, third-currency
-- labels, an invoice-labelled line without a figure, kontantmetoden,
-- or a missing/insane exchange rate.
RETURN jsonb_build_object(
'ok', false,
'code', 'LINK_VOUCHER_CURRENCY_MISMATCH',
'details', jsonb_build_object(
'invoice_currency', v_invoice.currency,
'line_currency', v_unreadable_currency
)
);
END IF;
END IF;
END IF;
v_ar_credit_total := ROUND(v_ar_credit_total * 100) / 100;
IF v_ar_credit_total <= 0 THEN
RETURN jsonb_build_object('ok', false, 'code', 'LINK_VOUCHER_NO_AR_CREDIT');
END IF;
-- Label guard, still load-bearing, but no longer as a unit check:
-- v_ar_credit_total is already in the invoice's currency. What it catches
-- now is a counterparty discriminator, a matched line stamped with some
-- other document's currency. Always passes on a foreign invoice, because
-- only same-labelled lines could be read at all. Both sides compare the
-- RESOLVED v_invoice_currency, never the raw nullable column: with the raw
-- column, a legacy NULL-currency invoice (which has always meant SEK) hit
-- 'SEK' IS DISTINCT FROM NULL = true and an ordinary domestic payment
-- raised LINK_VOUCHER_CURRENCY_MISMATCH forever.
IF COALESCE(v_line_currency, v_invoice_currency) IS DISTINCT FROM v_invoice_currency THEN
RETURN jsonb_build_object(
'ok', false,
'code', 'LINK_VOUCHER_CURRENCY_MISMATCH',
'details', jsonb_build_object(
'invoice_currency', v_invoice.currency,
'line_currency', v_line_currency
)
);
END IF;
-- Both sides are now in the invoice's currency.
IF v_ar_credit_total > v_remaining + 0.005 THEN
RETURN jsonb_build_object(
'ok', false,
'code', 'LINK_VOUCHER_AMOUNT_EXCEEDS_REMAINING',
'details', jsonb_build_object(
'ar_credit', v_ar_credit_total,
'remaining', ROUND(v_remaining * 100) / 100
)
);
END IF;
-- 4. Reject re-link of the same voucher to the same invoice. Authoritative
-- under the FOR UPDATE lock; the partial unique index
-- idx_invoice_payments_je_inv_unique stays as the last line of defence
-- for non-RPC writers.
IF EXISTS (
SELECT 1 FROM public.invoice_payments
WHERE company_id = p_company_id
AND invoice_id = p_invoice_id
AND journal_entry_id = p_journal_entry_id
) THEN
RETURN jsonb_build_object('ok', false, 'code', 'LINK_VOUCHER_ALREADY_LINKED');
END IF;
-- 4b. Book the FX residual as its OWN verifikat: the linked voucher is
-- posted and immutable, so the difference between the receivable's
-- booked kronor and the kronor the voucher settled cannot live inside
-- it. Placed AFTER every guard so a rejected link never creates a
-- verifikat; the RPC body is one transaction, so a later failure rolls
-- it back. v_fx_diff is öre-rounded from öre-rounded inputs: it is
-- either exactly 0.00 (nothing to book) or >= 0.01.
IF v_fx_settled AND ABS(v_fx_diff) > 0.005 THEN
-- Counter-leg account: the AR account the voucher actually settled
-- (largest matched-side line). Non-null: the fallback required
-- v_sek_side_total > 0, so at least one such line exists.
SELECT l.account_number INTO v_fx_account
FROM public.journal_entry_lines l
WHERE l.journal_entry_id = p_journal_entry_id
AND l.account_number LIKE v_account_prefix || '%'
AND l.credit_amount > 0
ORDER BY l.credit_amount DESC, l.account_number ASC
LIMIT 1;
-- Same period resolution + openness check as match_batch_allocate: the
-- residual is dated on the payment voucher's entry_date so the FX result
-- lands in the period the settlement happened in.
SELECT fp.id, fp.is_closed, fp.locked_at
INTO v_fiscal_period_id, v_period_is_closed, v_period_locked_at
FROM public.fiscal_periods fp
WHERE fp.company_id = p_company_id
AND v_voucher.entry_date BETWEEN fp.period_start AND fp.period_end
ORDER BY fp.period_start DESC
LIMIT 1;
IF v_fiscal_period_id IS NULL THEN
RETURN jsonb_build_object(
'ok', false,
'code', 'LINK_VOUCHER_CURRENCY_MISMATCH',
'details', jsonb_build_object(
'invoice_currency', v_invoice.currency,
'reason', 'fx_residual_no_fiscal_period',
'entry_date', v_voucher.entry_date
)
);
END IF;
IF v_period_is_closed OR v_period_locked_at IS NOT NULL THEN
RETURN jsonb_build_object(
'ok', false,
'code', 'LINK_VOUCHER_CURRENCY_MISMATCH',
'details', jsonb_build_object(
'invoice_currency', v_invoice.currency,
'reason', 'fx_residual_period_locked',
'fiscal_period_id', v_fiscal_period_id
)
);
END IF;
v_inv_number_short := LEFT(COALESCE(v_invoice.invoice_number, ''), 32);
v_fx_entry_id := gen_random_uuid();
INSERT INTO public.journal_entries
(id, user_id, company_id, fiscal_period_id, voucher_number, voucher_series,
entry_date, description, source_type, status)
VALUES
(v_fx_entry_id, v_acting_user, p_company_id, v_fiscal_period_id, 0, 'A',
v_voucher.entry_date, 'Valutakursdifferens faktura ' || v_inv_number_short,
'invoice_paid', 'draft');
IF v_fx_diff > 0 THEN
-- Settled below booked value: loss. Dr 7960 / Cr AR, the polarity
-- match_batch_allocate books for a customer allocation under booked_sek.
INSERT INTO public.journal_entry_lines
(journal_entry_id, account_number, debit_amount, credit_amount, currency,
sort_order, line_description)
VALUES
(v_fx_entry_id, '7960', v_fx_diff, 0, 'SEK', 0,
'Valutakursförlust ' || v_inv_number_short),
(v_fx_entry_id, v_fx_account, 0, v_fx_diff, 'SEK', 1,
'Faktura ' || v_inv_number_short || ' (' || v_invoice_currency || ')');
ELSE
-- Settled above booked value: gain. Dr AR / Cr 3960.
INSERT INTO public.journal_entry_lines
(journal_entry_id, account_number, debit_amount, credit_amount, currency,
sort_order, line_description)
VALUES
(v_fx_entry_id, v_fx_account, ABS(v_fx_diff), 0, 'SEK', 0,
'Faktura ' || v_inv_number_short || ' (' || v_invoice_currency || ')'),
(v_fx_entry_id, '3960', 0, ABS(v_fx_diff), 'SEK', 1,
'Valutakursvinst ' || v_inv_number_short);
END IF;
SELECT voucher_number INTO v_fx_voucher_number
FROM public.commit_journal_entry(p_company_id, v_fx_entry_id);
END IF;
-- 5. Compute the advance.
v_payment_amount := LEAST(v_ar_credit_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.invoices
SET status = v_new_status,
paid_at = CASE WHEN v_is_fully_paid THEN
((v_voucher.entry_date::timestamp + interval '12 hours') AT TIME ZONE 'UTC')
ELSE paid_at END,
paid_amount = v_new_paid,
remaining_amount = v_new_remaining,
updated_at = v_now
WHERE id = p_invoice_id;
-- The payment row persists the RESOLVED currency: writing the raw column
-- would store NULL for a legacy NULL-currency invoice, and the payment's
-- unit is a fact this row must state, not inherit as "unknown".
-- payment_exchange_rate carries the effective settlement rate on the FX
-- fallback (settled_sek / remaining) and stays NULL on every other path.
INSERT INTO public.invoice_payments (
user_id, company_id, invoice_id, payment_date, amount, currency,
exchange_rate, payment_exchange_rate, journal_entry_id, transaction_id, notes
) VALUES (
v_acting_user, p_company_id, p_invoice_id, v_voucher.entry_date,
v_payment_amount, v_invoice_currency, v_invoice.exchange_rate, v_payment_rate,
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,
'payment_date', v_voucher.entry_date,
'fx_settled_sek', CASE WHEN v_fx_settled THEN v_sek_side_total END,
'fx_residual_sek', CASE WHEN v_fx_settled THEN v_fx_diff END,
'fx_journal_entry_id', v_fx_entry_id,
'fx_voucher_number', v_fx_voucher_number
);
END;
$$;
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;
v_jwt_role text := coalesce(nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'role', '');
v_acting_user uuid := p_user_id;
-- Unit resolution (20260726140000), as in link_invoice_to_voucher above.
v_invoice_currency text;
v_unreadable_count integer := 0;
v_unreadable_currency text;
-- FX residual settlement (new), mirroring link_invoice_to_voucher. The
-- supplier side has no kontantmetoden branch: the matched side is always
-- the 244x debit.
v_readable_count integer := 0;
v_sek_side_total numeric := 0;
v_foreign_label_count integer := 0;
v_booked_sek numeric;
v_fx_diff numeric := 0;
v_fx_settled boolean := false;
v_payment_rate numeric; -- round-6 effective rate (traceability)
v_fx_account text;
v_fx_entry_id uuid;
v_fx_voucher_number int;
v_fiscal_period_id uuid;
v_period_is_closed boolean;
v_period_locked_at timestamptz;
v_inv_number_short text;
BEGIN
-- Tenant guard (mirrors 20260611140000): anon/authenticated may only act on
-- their own companies; service_role / direct access bypasses. NULL-safe
-- caller_is_company_member() form, as in link_invoice_to_voucher above.
IF v_jwt_role IN ('anon', 'authenticated') THEN
IF NOT public.caller_is_company_member(p_company_id) THEN
RETURN jsonb_build_object('ok', false, 'code', 'LINK_SI_VOUCHER_INVOICE_NOT_FOUND');
END IF;
-- Attribution: the JWT sub is authoritative for user-session callers:
-- p_user_id cannot point the payment row at someone else.
v_acting_user := coalesce(
(nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'sub')::uuid,
p_user_id
);
END IF;
IF p_notes IS NOT NULL AND char_length(p_notes) > 2000 THEN
RETURN jsonb_build_object(
'ok', false,
'code', 'LINK_SI_VOUCHER_NOTES_TOO_LONG',
'details', jsonb_build_object('max_length', 2000, 'length', char_length(p_notes))
);
END IF;
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 the AP debit across the full 244x range, EXPRESSED IN THE INVOICE'S
-- CURRENCY. `supplier_invoices.currency` is NOT NULL DEFAULT 'SEK', but the
-- COALESCE keeps this symmetric with the customer side.
v_invoice_currency := COALESCE(v_invoice.currency, 'SEK');
IF v_invoice_currency = 'SEK' THEN
-- VERBATIM from 20260615120000: the ledger column is kronor already.
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;
ELSE
-- Foreign supplier invoice. The three extra aggregates feed the FX
-- residual fallback, as in link_invoice_to_voucher above; the readable
-- gate counts LINES for the same reason (a readable line with
-- amount_in_currency = 0 must disable the fallback).
SELECT
COALESCE(SUM(ABS(l.amount_in_currency)) FILTER (
WHERE l.currency = v_invoice_currency AND l.amount_in_currency IS NOT NULL
), 0),
MAX(l.currency) FILTER (
WHERE l.currency = v_invoice_currency AND l.amount_in_currency IS NOT NULL
),
COUNT(*) FILTER (
WHERE l.currency IS DISTINCT FROM v_invoice_currency OR l.amount_in_currency IS NULL
),
MIN(l.currency) FILTER (
WHERE l.currency IS DISTINCT FROM v_invoice_currency OR l.amount_in_currency IS NULL
),
COUNT(*) FILTER (
WHERE l.currency = v_invoice_currency AND l.amount_in_currency IS NOT NULL
),
COALESCE(SUM(l.debit_amount) FILTER (
WHERE COALESCE(l.currency, 'SEK') = 'SEK'
), 0),
COUNT(*) FILTER (
WHERE (l.currency IS DISTINCT FROM v_invoice_currency OR l.amount_in_currency IS NULL)
AND COALESCE(l.currency, 'SEK') <> 'SEK'
)
INTO v_ap_debit_total, v_line_currency, v_unreadable_count, v_unreadable_currency,
v_readable_count, v_sek_side_total, v_foreign_label_count
FROM public.journal_entry_lines l
WHERE l.journal_entry_id = p_journal_entry_id
AND l.account_number LIKE '244%'
AND l.debit_amount > 0;
IF COALESCE(v_unreadable_count, 0) > 0 THEN
-- FX residual fallback (new): see link_invoice_to_voucher above.
IF COALESCE(v_readable_count, 0) = 0
AND COALESCE(v_foreign_label_count, 0) = 0
AND v_sek_side_total > 0
AND v_invoice.exchange_rate IS NOT NULL
AND v_invoice.exchange_rate > 0
AND v_invoice.exchange_rate < 100000
THEN
v_sek_side_total := ROUND(v_sek_side_total * 100) / 100;
v_booked_sek := ROUND(v_remaining * v_invoice.exchange_rate * 100) / 100;
IF ABS(v_sek_side_total - v_booked_sek) > v_booked_sek * 0.10 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_unreadable_currency,
'reason', 'fx_deviation_too_large',
'expected_sek', v_booked_sek,
'voucher_sek', v_sek_side_total
));
END IF;
v_fx_settled := true;
v_fx_diff := ROUND((v_booked_sek - v_sek_side_total) * 100) / 100;
v_ap_debit_total := ROUND(v_remaining * 100) / 100;
v_payment_rate := ROUND((v_sek_side_total / v_remaining) * 1000000) / 1000000;
ELSE
RETURN jsonb_build_object('ok', false, 'code', 'LINK_SI_VOUCHER_CURRENCY_MISMATCH',
'details', jsonb_build_object(
'invoice_currency', v_invoice.currency,
'line_currency', v_unreadable_currency
));
END IF;
END IF;
END IF;
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;
-- Label guard: a counterparty discriminator, not a unit check. Compares the
-- RESOLVED currency on both sides, as in link_invoice_to_voucher above.
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;
-- Both sides are now in the invoice's currency.
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;
-- Book the FX residual as its OWN verifikat, after every guard: see
-- link_invoice_to_voucher above. Supplier polarity per match_batch_allocate:
-- paid less SEK than booked = gain (Cr 3960), paid more = loss (Dr 7960).
IF v_fx_settled AND ABS(v_fx_diff) > 0.005 THEN
SELECT l.account_number INTO v_fx_account
FROM public.journal_entry_lines l
WHERE l.journal_entry_id = p_journal_entry_id
AND l.account_number LIKE '244%'
AND l.debit_amount > 0
ORDER BY l.debit_amount DESC, l.account_number ASC
LIMIT 1;
SELECT fp.id, fp.is_closed, fp.locked_at
INTO v_fiscal_period_id, v_period_is_closed, v_period_locked_at
FROM public.fiscal_periods fp
WHERE fp.company_id = p_company_id
AND v_voucher.entry_date BETWEEN fp.period_start AND fp.period_end
ORDER BY fp.period_start DESC
LIMIT 1;
IF v_fiscal_period_id IS NULL THEN
RETURN jsonb_build_object('ok', false, 'code', 'LINK_SI_VOUCHER_CURRENCY_MISMATCH',
'details', jsonb_build_object(
'invoice_currency', v_invoice.currency,
'reason', 'fx_residual_no_fiscal_period',
'entry_date', v_voucher.entry_date
));
END IF;
IF v_period_is_closed OR v_period_locked_at IS NOT NULL THEN
RETURN jsonb_build_object('ok', false, 'code', 'LINK_SI_VOUCHER_CURRENCY_MISMATCH',
'details', jsonb_build_object(
'invoice_currency', v_invoice.currency,
'reason', 'fx_residual_period_locked',
'fiscal_period_id', v_fiscal_period_id
));
END IF;
v_inv_number_short := LEFT(COALESCE(v_invoice.supplier_invoice_number, ''), 32);
v_fx_entry_id := gen_random_uuid();
INSERT INTO public.journal_entries
(id, user_id, company_id, fiscal_period_id, voucher_number, voucher_series,
entry_date, description, source_type, status)
VALUES
(v_fx_entry_id, v_acting_user, p_company_id, v_fiscal_period_id, 0, 'A',
v_voucher.entry_date, 'Valutakursdifferens leverantörsfaktura ' || v_inv_number_short,
'supplier_invoice_paid', 'draft');
IF v_fx_diff > 0 THEN
-- Paid less than the booked liability: gain. Dr 244x / Cr 3960.
INSERT INTO public.journal_entry_lines
(journal_entry_id, account_number, debit_amount, credit_amount, currency,
sort_order, line_description)
VALUES
(v_fx_entry_id, v_fx_account, v_fx_diff, 0, 'SEK', 0,
'Leverantörsfaktura ' || v_inv_number_short || ' (' || v_invoice_currency || ')'),
(v_fx_entry_id, '3960', 0, v_fx_diff, 'SEK', 1,
'Valutakursvinst ' || v_inv_number_short);
ELSE
-- Paid more than the booked liability: loss. Dr 7960 / Cr 244x.
INSERT INTO public.journal_entry_lines
(journal_entry_id, account_number, debit_amount, credit_amount, currency,
sort_order, line_description)
VALUES
(v_fx_entry_id, '7960', ABS(v_fx_diff), 0, 'SEK', 0,
'Valutakursförlust ' || v_inv_number_short),
(v_fx_entry_id, v_fx_account, 0, ABS(v_fx_diff), 'SEK', 1,
'Leverantörsfaktura ' || v_inv_number_short || ' (' || v_invoice_currency || ')');
END IF;
SELECT voucher_number INTO v_fx_voucher_number
FROM public.commit_journal_entry(p_company_id, v_fx_entry_id);
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_voucher.entry_date::timestamp + interval '12 hours') AT TIME ZONE 'UTC')
ELSE paid_at END,
paid_amount = v_new_paid,
remaining_amount = v_new_remaining,
updated_at = v_now
WHERE id = p_supplier_invoice_id;
-- payment_exchange_rate: effective settlement rate on the FX fallback
-- (settled_sek / remaining), NULL on every other path.
INSERT INTO public.supplier_invoice_payments (
user_id, company_id, supplier_invoice_id, payment_date, amount, currency,
payment_exchange_rate, journal_entry_id, transaction_id, notes
) VALUES (
v_acting_user, p_company_id, p_supplier_invoice_id, v_voucher.entry_date,
v_payment_amount, v_invoice_currency, v_payment_rate, 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,
'fx_settled_sek', CASE WHEN v_fx_settled THEN v_sek_side_total END,
'fx_residual_sek', CASE WHEN v_fx_settled THEN v_fx_diff END,
'fx_journal_entry_id', v_fx_entry_id,
'fx_voucher_number', v_fx_voucher_number
);
END;
$$;
-- Grants are unchanged and restated because CREATE OR REPLACE does not alter
-- them: `authenticated` covers user-session clients, `service_role` the
-- MCP / API-key paths.
REVOKE ALL ON FUNCTION public.link_invoice_to_voucher(uuid, uuid, uuid, uuid, text) FROM PUBLIC, anon;
GRANT EXECUTE ON FUNCTION public.link_invoice_to_voucher(uuid, uuid, uuid, uuid, text) TO authenticated, service_role;
REVOKE ALL ON FUNCTION public.link_supplier_invoice_to_voucher(uuid, uuid, uuid, uuid, text) FROM PUBLIC, anon;
GRANT EXECUTE ON FUNCTION public.link_supplier_invoice_to_voucher(uuid, uuid, uuid, uuid, text) TO authenticated, service_role;
COMMENT ON FUNCTION public.link_invoice_to_voucher(uuid, uuid, uuid, uuid, text) IS
'Link a posted verifikat to a customer invoice as its payment. Accounting-method aware (151x credit on faktureringsmetoden, 19xx debit on kontantmetoden). The matched amount is resolved in the INVOICE''S currency: the raw ledger column on a SEK invoice, ABS(amount_in_currency) on a foreign one. A foreign invoice settled by a plain-SEK voucher (accrual only, within 10% of remaining * exchange_rate) is treated as fully settled and the FX residual is booked as its own verifikat to 7960 (loss) / 3960 (gain); every other unreadable case is refused.';
COMMENT ON FUNCTION public.link_supplier_invoice_to_voucher(uuid, uuid, uuid, uuid, text) IS
'Link a posted verifikat to a supplier invoice as its payment, summing the 244x debit in the INVOICE''S currency: the raw ledger column on a SEK invoice, ABS(amount_in_currency) on a foreign one. A foreign invoice paid by a plain-SEK voucher (within 10% of remaining * exchange_rate) is treated as fully settled and the FX residual is booked as its own verifikat to 3960 (gain) / 7960 (loss); every other unreadable case is refused.';
NOTIFY pgrst, 'reload schema';