7162053e89
* 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>
892 lines
40 KiB
PL/PgSQL
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';
|