Files
accounted/supabase/migrations/20260531120000_match_batch_allocate_cross_currency.sql
Jakob Wennberg fc7a46c3f2 fix(match-batch): cross-currency allocations + widened tolerance (#607)
* fix(match-batch): cross-currency allocations + widened tolerance

Reported by jakob testing PR #603's MatchAllocationDialog with a SEK
bank tx + a mix of SEK and USD invoices:

1. Tally rendered "1 USD + 1 SEK = 2 kr" — summing different currencies
   as if they were the same.
2. The 0.005 SEK tolerance blocked confirm on any FX rounding delta.

## What changed

**UI (MatchAllocationDialog.tsx)**
- Per-row amount input is explicitly in TRANSACTION currency (SEK for a
  Swedish bank import). Cross-currency rows show an "≈ X.XX (invoice
  currency)" hint under the input so the user can verify the FX result.
- Default amount for a cross-currency allocation is
  `invoice.remaining × invoice.exchange_rate` (booked SEK), so the user
  doesn't have to mental-math the FX.
- Overshoot tolerance widened from 0.005 SEK to `max(1 SEK, 0.5% × tx)`
  so bank-side FX rounding doesn't block confirm. A 2 400 kr tx now
  accepts ~12 kr of tolerance, a 100 kkr transfer accepts 500 kr.

**RPC (match_batch_allocate cross_currency migration)**
- BATCH_CURRENCY_MISMATCH dropped per-allocation. Mixed currencies now
  accepted with the convention that the cross-currency row pays the
  FULL invoice remaining (matches the single-tx match-supplier-invoice
  behavior). Partial cross-currency is out of scope for v1.
- AR/AP line is booked at `invoice.remaining × invoice.exchange_rate`
  (the SEK that was originally on 1510/2440). FX residual is posted
  to 7960 (Valutakursförluster) or 3960 (Valutakursvinster) per BAS.
- Sign conventions per direction documented inline:
    Customer: bank > booked → Cr 3960 (gain); bank < booked → Dr 7960
    Supplier: bank < booked → Cr 3960 (gain); bank > booked → Dr 7960
- New BATCH_FX_RATE_MISSING when the cross-currency invoice has no
  exchange_rate on file (would otherwise silently book at 0).
- New BATCH_FX_DEVIATION_TOO_LARGE when the user-entered amount
  deviates more than 10% from booked SEK — catches typos like "140"
  (USD invoice currency) when they meant "1390" (SEK equivalent)
  without rejecting genuine rate-day FX movement.

RPC patched on remote via Supabase MCP. Same-currency path is
byte-identical to the previous behavior.

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

* fix(match-batch): PR review — strict sum, bank line = tx_abs, FX validation

Round-1 review fixes on the cross-currency batch allocation flow:

UI (MatchAllocationDialog):
- Tighten tolerance to 0.005 SEK so the "balanced ✓" indicator matches
  what the server will accept. The previous widened tolerance (max 1 SEK
  or 0.5% × tx) created a reconciliation gap where the JE's bank line
  could legitimately disagree with the actual bank receipt.
- Require balanced before confirm — undershoot is now a blocking state
  with an explicit warning, not a silent "leave unallocated".
- Cross-currency default no longer caps at remainingTxBudget. Capping a
  USD invoice's default to the leftover SEK budget could silently
  trigger BATCH_FX_DEVIATION_TOO_LARGE on submit. The user re-balances
  the other rows to fit.
- Add explicit FX-rate validation (bound check 0 < rate < 100000).
- When a cross-currency invoice has no usable exchange_rate on file,
  leave the amount blank and surface a warning instead of guessing.

RPC (match_batch_allocate):
- New code BATCH_AMOUNT_BELOW_TX. Strict sum check on both sides means
  the server can't be coaxed by a direct API caller into the same
  broken state the UI now blocks.
- Bank line credit/debit = v_tx_abs (the actual bank movement) instead
  of sum-of-allocations. Same value within rounding under the strict
  sum check, but it makes intent legible and lets per-row FX diff lines
  absorb rounding.
- Defense-in-depth company_id filter on all re-queries / UPDATEs in
  the line-build + payment-row passes.
- Drop the v_booked_sek-aliasing-for-invoice.total foot-gun. Use a
  dedicated v_inv_total var.
- Truncate invoice_number to 32 chars in line_description.

Tests:
- pg-real: cross-currency happy path (USD invoice paid by SEK tx with
  FX loss to 7960, bank line = tx_abs).
- pg-real: BATCH_AMOUNT_BELOW_TX rejection on undershoot.

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

* fix(match-batch): PR review round 2 - caller user_id verification + FX bound

Compliance-swarm + swedish-compliance findings on round 1:

- CC6.3 (HIGH): p_user_id was caller-supplied and written into
  journal_entries.user_id / payment-row user_id without verifying it
  equals auth.uid(). Membership covered the company; nothing covered
  the user attribution. Two-layer fix: explicit guard rejects when
  p_user_id <> auth.uid(), and all writes now resolve v_caller =
  auth.uid() directly so the guard cant be silently bypassed.
- A.8.28 (MED): server-side FX upper-bound (0 < rate < 100000) matches
  the UI. Previously RPC only checked > 0, allowing the UI guard to
  diverge.
- V1.2.5 (LOW): truncate v_tx.date when concatenated into
  line_description (defense alongside round 1s invoice_number trunc).
- Symmetry: populate supplier_invoice_payments.exchange_rate (column
  existed, INSERT omitted it). Customer side already populated. Matches
  swedish-compliances traceability note on AP rorelseskulder.

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

* fix(match-batch): PR review round 3 - drop p_user_id, CHECK constraints, payment-day rate

Genuine round-2 review findings (compliance-swarm + swedish-compliance):

- V4.5: p_user_id dropped from RPC signature entirely. Round-2 added a
  guard; this removes the attack surface at the API boundary. Caller is
  resolved via auth.uid() inside the function. Route updated.
- V2.2: CHECK constraint on invoices.exchange_rate and
  supplier_invoices.exchange_rate (0 < rate < 100000). Three layers now
  enforce the bound: schema, RPC, UI.
- swedish-compliance traceability gap: payment_exchange_rate column on
  both invoice_payments and supplier_invoice_payments. Populated as
  v_alloc_amount / v_inv_remaining for cross-currency rows so FX diffs
  are reconstructible from the payment record alone (BFL 7 kap
  behandlingshistorik). NULL for same-currency. The existing
  exchange_rate column continues to store the invoicing rate.
- CC6.1: extract isValidExchangeRate() to lib/utils.ts. UI's three
  inline bound checks now share one validator.
- Dead code: drop unused leftover_note i18n key (sv + en).

Tests:
- pg-real signature updated (4-arg -> 3-arg) across all 9 call sites.
- Added payment_exchange_rate assertion to cross-currency happy path
  (invoicing rate 10.0 stays, payment-day rate stored as 10.5).

Migration applied to remote.

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

* fix(test): missed 4th arg in BATCH_UNAUTHORIZED pg-real test

Round-3 dropped p_user_id from match_batch_allocate. The replace_all
caught the userId/companyId pattern but missed the BATCH_UNAUTHORIZED
test which uses outsiderId instead of userId. CI failed with
"bind message supplies 4 parameters, but prepared statement requires 3".

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 16:30:42 +02:00

494 lines
24 KiB
PL/PgSQL
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
-- PR #607 — match_batch_allocate cross-currency support.
--
-- Drops the BATCH_CURRENCY_MISMATCH guard for allocations where the
-- invoice's currency differs from the transaction's. In that case the
-- allocation pays the FULL remaining of the invoice (matches the
-- single-tx match-supplier-invoice convention; partial cross-currency
-- payments are not supported in v1). The FX residual is posted to
-- 7960 (Valutakursförluster) or 3960 (Valutakursvinster) per BAS 2026.
--
-- Allocation.amount is interpreted as the TRANSACTION currency for every
-- row, including cross-currency rows — the bank tx already carries the
-- bank-side conversion. The invoice's stored exchange_rate is used to
-- compute the SEK amount that the AR/AP account was booked at when the
-- invoice was created (bookedSek). The FX diff is bookedSek vs the
-- allocation.amount (the actual bank movement).
--
-- Sign conventions per direction:
-- - Customer (income): bookedSek - allocation.amount = AR shortage
-- positive → received LESS SEK than booked → Dr 7960 (loss)
-- negative → received MORE SEK than booked → Cr 3960 (gain)
-- - Supplier (expense): bookedSek - allocation.amount = AP shortage
-- positive → paid LESS SEK than booked → Cr 3960 (gain)
-- negative → paid MORE SEK than booked → Dr 7960 (loss)
--
-- This patch only changes the validation + line-construction loops.
-- Everything else (caller membership check, dedupe, deadlock-stable
-- locking, period resolution, payment-row inserts) stays byte-identical.
CREATE OR REPLACE FUNCTION public.match_batch_allocate(
p_tx_id uuid,
p_allocations jsonb,
p_user_id uuid,
p_company_id uuid
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $$
DECLARE
v_tx RECORD;
v_tx_abs numeric;
v_allocation jsonb;
v_alloc_index int := 0;
v_kind text;
v_invoice_id uuid;
v_supplier_invoice_id uuid;
v_alloc_amount numeric;
v_total_allocated numeric := 0;
v_has_customer boolean := false;
v_has_supplier boolean := false;
v_seen_ids text[] := ARRAY[]::text[];
v_target_id text;
v_invoice RECORD;
v_si_invoice RECORD;
v_supplier_name text;
v_supplier_invoice_number text;
v_invoice_number text;
v_fiscal_period_id uuid;
v_period_is_closed boolean;
v_period_locked_at timestamptz;
v_journal_entry_id uuid := gen_random_uuid();
v_voucher_series text := 'A';
v_voucher_number int;
v_entry_description text;
v_source_type text;
v_line_sort_order int := 0;
v_new_paid numeric;
v_new_remaining numeric;
v_new_status text;
v_now timestamptz := now();
v_payment_id uuid;
v_results jsonb := '[]'::jsonb;
-- Cross-currency support locals
v_inv_remaining numeric;
v_inv_currency text;
v_inv_fx_rate numeric;
v_booked_sek numeric; -- amount on 1510/2440 in SEK at booking time
v_fx_diff numeric; -- bookedSek - allocation.amount (signed)
v_paid_in_inv_currency numeric; -- what goes into payment row
BEGIN
IF NOT EXISTS (
SELECT 1 FROM public.company_members
WHERE user_id = auth.uid() AND company_id = p_company_id
) THEN
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_UNAUTHORIZED');
END IF;
SELECT * INTO v_tx FROM public.transactions
WHERE id = p_tx_id AND company_id = p_company_id FOR UPDATE;
IF NOT FOUND THEN RETURN jsonb_build_object('ok', false, 'code', 'BATCH_TX_NOT_FOUND'); END IF;
IF v_tx.journal_entry_id IS NOT NULL THEN
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_TX_ALREADY_BOOKED',
'details', jsonb_build_object('journal_entry_id', v_tx.journal_entry_id));
END IF;
IF v_tx.amount = 0 THEN RETURN jsonb_build_object('ok', false, 'code', 'BATCH_TX_ZERO_AMOUNT'); END IF;
v_tx_abs := ABS(v_tx.amount);
IF jsonb_typeof(p_allocations) IS DISTINCT FROM 'array' OR jsonb_array_length(p_allocations) = 0 THEN
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_NO_ALLOCATIONS');
END IF;
-- Validation pass with deadlock-stable ordering. The currency check
-- that was here (BATCH_CURRENCY_MISMATCH per allocation) is GONE — we
-- now accept cross-currency rows and compute FX diff downstream.
-- For cross-currency, the allocation.amount must be approximately
-- invoice.remaining × invoice.exchange_rate (within 10%) so a typo
-- ("140" when they meant "1390" for a USD invoice) doesn't silently
-- credit the wrong bank chunk.
FOR v_allocation IN
SELECT value FROM jsonb_array_elements(p_allocations) AS t(value)
ORDER BY COALESCE(value->>'invoice_id', value->>'supplier_invoice_id', '')
LOOP
v_kind := v_allocation->>'kind';
v_alloc_amount := (v_allocation->>'amount')::numeric;
v_target_id := COALESCE(v_allocation->>'invoice_id', v_allocation->>'supplier_invoice_id');
IF v_alloc_amount IS NULL OR v_alloc_amount <= 0 THEN
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_INVALID_AMOUNT',
'details', jsonb_build_object('index', v_alloc_index, 'amount', v_alloc_amount));
END IF;
IF v_target_id IS NOT NULL AND v_target_id = ANY(v_seen_ids) THEN
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_DUPLICATE_ALLOCATION',
'details', jsonb_build_object('id', v_target_id, 'index', v_alloc_index));
END IF;
IF v_target_id IS NOT NULL THEN v_seen_ids := array_append(v_seen_ids, v_target_id); END IF;
v_total_allocated := v_total_allocated + v_alloc_amount;
IF v_kind = 'customer_invoice' THEN
v_has_customer := true;
v_invoice_id := (v_allocation->>'invoice_id')::uuid;
SELECT * INTO v_invoice FROM public.invoices
WHERE id = v_invoice_id AND company_id = p_company_id FOR UPDATE;
IF NOT FOUND THEN
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_INVOICE_NOT_FOUND',
'details', jsonb_build_object('index', v_alloc_index, 'invoice_id', v_invoice_id));
END IF;
IF v_invoice.status NOT IN ('sent', 'overdue', 'partially_paid') THEN
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_INVOICE_NOT_OPEN',
'details', jsonb_build_object('index', v_alloc_index, 'invoice_id', v_invoice_id, 'status', v_invoice.status));
END IF;
v_inv_remaining := COALESCE(v_invoice.remaining_amount, v_invoice.total);
v_inv_currency := v_invoice.currency;
v_inv_fx_rate := v_invoice.exchange_rate;
IF v_inv_currency = v_tx.currency THEN
-- Same-currency: existing partial-allowed semantics.
IF v_alloc_amount > v_inv_remaining + 0.005 THEN
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_OVERSHOOT',
'details', jsonb_build_object('index', v_alloc_index, 'invoice_id', v_invoice_id,
'requested', v_alloc_amount, 'remaining', v_inv_remaining));
END IF;
ELSE
-- Cross-currency: full-payment only. The allocation.amount is the
-- SEK the bank actually credited; we sanity-check it against
-- invoice.remaining × exchange_rate so an obvious typo doesn't
-- silently misrepresent the FX diff. ±10% catches the typo while
-- letting genuine rate-day movement through.
IF v_inv_fx_rate IS NULL OR v_inv_fx_rate <= 0 THEN
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_FX_RATE_MISSING',
'details', jsonb_build_object('index', v_alloc_index, 'invoice_id', v_invoice_id,
'invoice_currency', v_inv_currency));
END IF;
v_booked_sek := ROUND(v_inv_remaining * v_inv_fx_rate * 100) / 100;
IF ABS(v_alloc_amount - v_booked_sek) > v_booked_sek * 0.10 THEN
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_FX_DEVIATION_TOO_LARGE',
'details', jsonb_build_object('index', v_alloc_index, 'invoice_id', v_invoice_id,
'allocation_amount', v_alloc_amount, 'expected_sek', v_booked_sek));
END IF;
END IF;
ELSIF v_kind = 'supplier_invoice' THEN
v_has_supplier := true;
v_supplier_invoice_id := (v_allocation->>'supplier_invoice_id')::uuid;
SELECT * INTO v_si_invoice FROM public.supplier_invoices
WHERE id = v_supplier_invoice_id AND company_id = p_company_id FOR UPDATE;
IF NOT FOUND THEN
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_SUPPLIER_INVOICE_NOT_FOUND',
'details', jsonb_build_object('index', v_alloc_index, 'supplier_invoice_id', v_supplier_invoice_id));
END IF;
IF v_si_invoice.status NOT IN ('registered', 'approved', 'overdue', 'partially_paid') THEN
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_SUPPLIER_INVOICE_NOT_OPEN',
'details', jsonb_build_object('index', v_alloc_index, 'supplier_invoice_id', v_supplier_invoice_id, 'status', v_si_invoice.status));
END IF;
v_inv_remaining := COALESCE(v_si_invoice.remaining_amount, v_si_invoice.total);
v_inv_currency := v_si_invoice.currency;
v_inv_fx_rate := v_si_invoice.exchange_rate;
IF v_inv_currency = v_tx.currency THEN
IF v_alloc_amount > v_inv_remaining + 0.005 THEN
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_OVERSHOOT',
'details', jsonb_build_object('index', v_alloc_index, 'supplier_invoice_id', v_supplier_invoice_id,
'requested', v_alloc_amount, 'remaining', v_inv_remaining));
END IF;
ELSE
IF v_inv_fx_rate IS NULL OR v_inv_fx_rate <= 0 THEN
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_FX_RATE_MISSING',
'details', jsonb_build_object('index', v_alloc_index, 'supplier_invoice_id', v_supplier_invoice_id,
'invoice_currency', v_inv_currency));
END IF;
v_booked_sek := ROUND(v_inv_remaining * v_inv_fx_rate * 100) / 100;
IF ABS(v_alloc_amount - v_booked_sek) > v_booked_sek * 0.10 THEN
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_FX_DEVIATION_TOO_LARGE',
'details', jsonb_build_object('index', v_alloc_index, 'supplier_invoice_id', v_supplier_invoice_id,
'allocation_amount', v_alloc_amount, 'expected_sek', v_booked_sek));
END IF;
END IF;
ELSE
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_INVALID_KIND',
'details', jsonb_build_object('index', v_alloc_index, 'kind', v_kind));
END IF;
v_alloc_index := v_alloc_index + 1;
END LOOP;
IF v_has_customer AND v_has_supplier THEN
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_MIXED_KINDS_UNSUPPORTED');
END IF;
IF v_total_allocated > v_tx_abs + 0.005 THEN
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_AMOUNT_EXCEEDS_TX',
'details', jsonb_build_object('allocated', v_total_allocated, 'tx_amount_abs', v_tx_abs));
END IF;
IF v_has_customer AND v_tx.amount <= 0 THEN
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_DIRECTION_MISMATCH',
'details', jsonb_build_object('expected', 'income', 'tx_amount', v_tx.amount));
END IF;
IF v_has_supplier AND v_tx.amount >= 0 THEN
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_DIRECTION_MISMATCH',
'details', jsonb_build_object('expected', 'expense', 'tx_amount', v_tx.amount));
END IF;
SELECT id, is_closed, locked_at INTO v_fiscal_period_id, v_period_is_closed, v_period_locked_at
FROM public.fiscal_periods
WHERE company_id = p_company_id AND v_tx.date BETWEEN period_start AND period_end
ORDER BY period_start DESC LIMIT 1;
IF v_fiscal_period_id IS NULL THEN
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_NO_FISCAL_PERIOD',
'details', jsonb_build_object('tx_date', v_tx.date));
END IF;
IF v_period_is_closed OR v_period_locked_at IS NOT NULL THEN
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_PERIOD_LOCKED',
'details', jsonb_build_object('fiscal_period_id', v_fiscal_period_id,
'is_closed', v_period_is_closed, 'locked_at', v_period_locked_at));
END IF;
v_entry_description := CASE WHEN v_has_customer THEN 'Samlingsinbetalning ' || v_tx.date ELSE 'Samlingsbetalning ' || v_tx.date END;
v_source_type := CASE WHEN v_has_customer THEN 'invoice_paid' ELSE 'supplier_invoice_paid' END;
INSERT INTO public.journal_entries
(id, user_id, company_id, fiscal_period_id, voucher_number, voucher_series,
entry_date, description, source_type, status)
VALUES
(v_journal_entry_id, p_user_id, p_company_id, v_fiscal_period_id, 0, v_voucher_series,
v_tx.date, v_entry_description, v_source_type, 'draft');
-- Line-build pass. For each allocation:
-- - Same-currency: one AR/AP line at the allocation.amount.
-- - Cross-currency: one AR/AP line at bookedSek + one FX-diff line.
-- Bank line on 1930 is the sum of allocation.amount (all SEK).
v_alloc_index := 0;
FOR v_allocation IN
SELECT value FROM jsonb_array_elements(p_allocations) AS t(value)
ORDER BY COALESCE(value->>'invoice_id', value->>'supplier_invoice_id', '')
LOOP
v_alloc_amount := (v_allocation->>'amount')::numeric;
IF v_has_customer THEN
v_invoice_id := (v_allocation->>'invoice_id')::uuid;
SELECT invoice_number, currency, exchange_rate, remaining_amount, total
INTO v_invoice_number, v_inv_currency, v_inv_fx_rate, v_inv_remaining, v_booked_sek
FROM public.invoices WHERE id = v_invoice_id;
v_inv_remaining := COALESCE(v_inv_remaining, v_booked_sek); -- v_booked_sek temp = invoice.total
IF v_inv_currency = v_tx.currency THEN
-- Same-currency AR line.
INSERT INTO public.journal_entry_lines
(journal_entry_id, account_number, debit_amount, credit_amount, currency,
sort_order, line_description)
VALUES
(v_journal_entry_id, '1510', 0, v_alloc_amount, v_tx.currency, v_line_sort_order,
'Faktura ' || COALESCE(v_invoice_number, ''));
v_line_sort_order := v_line_sort_order + 1;
ELSE
-- Cross-currency: AR booked at invoice's rate, FX diff to 7960/3960.
v_booked_sek := ROUND(v_inv_remaining * v_inv_fx_rate * 100) / 100;
v_fx_diff := ROUND((v_booked_sek - v_alloc_amount) * 100) / 100;
INSERT INTO public.journal_entry_lines
(journal_entry_id, account_number, debit_amount, credit_amount, currency,
sort_order, line_description)
VALUES
(v_journal_entry_id, '1510', 0, v_booked_sek, v_tx.currency, v_line_sort_order,
'Faktura ' || COALESCE(v_invoice_number, '') || ' (' || v_inv_currency || ')');
v_line_sort_order := v_line_sort_order + 1;
IF ABS(v_fx_diff) > 0.005 THEN
IF v_fx_diff > 0 THEN
-- bookedSek > received → AR shortage → loss → Dr 7960
INSERT INTO public.journal_entry_lines
(journal_entry_id, account_number, debit_amount, credit_amount, currency,
sort_order, line_description)
VALUES
(v_journal_entry_id, '7960', v_fx_diff, 0, v_tx.currency, v_line_sort_order,
'Valutakursförlust ' || COALESCE(v_invoice_number, ''));
ELSE
-- received > bookedSek → gain → Cr 3960
INSERT INTO public.journal_entry_lines
(journal_entry_id, account_number, debit_amount, credit_amount, currency,
sort_order, line_description)
VALUES
(v_journal_entry_id, '3960', 0, ABS(v_fx_diff), v_tx.currency, v_line_sort_order,
'Valutakursvinst ' || COALESCE(v_invoice_number, ''));
END IF;
v_line_sort_order := v_line_sort_order + 1;
END IF;
END IF;
ELSE
v_supplier_invoice_id := (v_allocation->>'supplier_invoice_id')::uuid;
SELECT si.supplier_invoice_number, s.name, si.currency, si.exchange_rate,
si.remaining_amount, si.total
INTO v_supplier_invoice_number, v_supplier_name, v_inv_currency, v_inv_fx_rate,
v_inv_remaining, v_booked_sek
FROM public.supplier_invoices si LEFT JOIN public.suppliers s ON s.id = si.supplier_id
WHERE si.id = v_supplier_invoice_id;
v_inv_remaining := COALESCE(v_inv_remaining, v_booked_sek);
IF v_inv_currency = v_tx.currency THEN
INSERT INTO public.journal_entry_lines
(journal_entry_id, account_number, debit_amount, credit_amount, currency,
sort_order, line_description)
VALUES
(v_journal_entry_id, '2440', v_alloc_amount, 0, v_tx.currency, v_line_sort_order,
TRIM(BOTH ' - ' FROM COALESCE(v_supplier_name, '') || ' - ' || COALESCE(v_supplier_invoice_number, '')));
v_line_sort_order := v_line_sort_order + 1;
ELSE
v_booked_sek := ROUND(v_inv_remaining * v_inv_fx_rate * 100) / 100;
v_fx_diff := ROUND((v_booked_sek - v_alloc_amount) * 100) / 100;
INSERT INTO public.journal_entry_lines
(journal_entry_id, account_number, debit_amount, credit_amount, currency,
sort_order, line_description)
VALUES
(v_journal_entry_id, '2440', v_booked_sek, 0, v_tx.currency, v_line_sort_order,
TRIM(BOTH ' - ' FROM
COALESCE(v_supplier_name, '') || ' - ' || COALESCE(v_supplier_invoice_number, '')
|| ' (' || v_inv_currency || ')'));
v_line_sort_order := v_line_sort_order + 1;
IF ABS(v_fx_diff) > 0.005 THEN
IF v_fx_diff > 0 THEN
-- bookedAP > paidSEK → we paid less than booked → gain → Cr 3960
INSERT INTO public.journal_entry_lines
(journal_entry_id, account_number, debit_amount, credit_amount, currency,
sort_order, line_description)
VALUES
(v_journal_entry_id, '3960', 0, v_fx_diff, v_tx.currency, v_line_sort_order,
'Valutakursvinst ' || COALESCE(v_supplier_invoice_number, ''));
ELSE
-- bookedAP < paidSEK → we paid more than booked → loss → Dr 7960
INSERT INTO public.journal_entry_lines
(journal_entry_id, account_number, debit_amount, credit_amount, currency,
sort_order, line_description)
VALUES
(v_journal_entry_id, '7960', ABS(v_fx_diff), 0, v_tx.currency, v_line_sort_order,
'Valutakursförlust ' || COALESCE(v_supplier_invoice_number, ''));
END IF;
v_line_sort_order := v_line_sort_order + 1;
END IF;
END IF;
END IF;
v_alloc_index := v_alloc_index + 1;
END LOOP;
-- Bank settlement line — sum of allocation.amount (all in SEK).
IF v_has_customer THEN
INSERT INTO public.journal_entry_lines
(journal_entry_id, account_number, debit_amount, credit_amount, currency,
sort_order, line_description)
VALUES
(v_journal_entry_id, '1930', v_total_allocated, 0, v_tx.currency, v_line_sort_order,
'Inbetalning ' || v_tx.date);
ELSE
INSERT INTO public.journal_entry_lines
(journal_entry_id, account_number, debit_amount, credit_amount, currency,
sort_order, line_description)
VALUES
(v_journal_entry_id, '1930', 0, v_total_allocated, v_tx.currency, v_line_sort_order,
'Utbetalning ' || v_tx.date);
END IF;
SELECT voucher_number INTO v_voucher_number FROM public.commit_journal_entry(p_company_id, v_journal_entry_id);
-- Payment-row inserts + invoice advance. Cross-currency rows mark the
-- invoice as fully paid (remaining = 0) and store the invoice-currency
-- amount in the payment row, matching match-supplier-invoice behavior.
v_alloc_index := 0;
FOR v_allocation IN
SELECT value FROM jsonb_array_elements(p_allocations) AS t(value)
ORDER BY COALESCE(value->>'invoice_id', value->>'supplier_invoice_id', '')
LOOP
v_alloc_amount := (v_allocation->>'amount')::numeric;
IF v_has_customer THEN
v_invoice_id := (v_allocation->>'invoice_id')::uuid;
SELECT * INTO v_invoice FROM public.invoices WHERE id = v_invoice_id;
IF v_invoice.currency = v_tx.currency THEN
v_paid_in_inv_currency := v_alloc_amount;
ELSE
v_paid_in_inv_currency := COALESCE(v_invoice.remaining_amount, v_invoice.total);
END IF;
v_new_paid := ROUND((COALESCE(v_invoice.paid_amount, 0) + v_paid_in_inv_currency) * 100) / 100;
v_new_remaining := GREATEST(0,
ROUND((COALESCE(v_invoice.remaining_amount, v_invoice.total) - v_paid_in_inv_currency) * 100) / 100);
v_new_status := CASE WHEN v_new_remaining <= 0.005 THEN 'paid' ELSE 'partially_paid' END;
UPDATE public.invoices SET status = v_new_status,
paid_at = CASE WHEN v_new_status = 'paid' THEN v_now ELSE paid_at END,
paid_amount = v_new_paid, remaining_amount = v_new_remaining, updated_at = v_now
WHERE id = v_invoice_id;
INSERT INTO public.invoice_payments
(user_id, company_id, invoice_id, payment_date, amount, currency, exchange_rate,
journal_entry_id, transaction_id)
VALUES
(p_user_id, p_company_id, v_invoice_id, v_tx.date, v_paid_in_inv_currency, v_invoice.currency,
v_invoice.exchange_rate, v_journal_entry_id, p_tx_id)
RETURNING id INTO v_payment_id;
v_results := v_results || jsonb_build_array(jsonb_build_object(
'kind', 'customer_invoice', 'invoice_id', v_invoice_id, 'payment_id', v_payment_id,
'status', v_new_status, 'paid_amount', v_new_paid, 'remaining_amount', v_new_remaining,
'amount', v_alloc_amount,
'cross_currency', v_invoice.currency <> v_tx.currency));
ELSE
v_supplier_invoice_id := (v_allocation->>'supplier_invoice_id')::uuid;
SELECT * INTO v_si_invoice FROM public.supplier_invoices WHERE id = v_supplier_invoice_id;
IF v_si_invoice.currency = v_tx.currency THEN
v_paid_in_inv_currency := v_alloc_amount;
ELSE
v_paid_in_inv_currency := COALESCE(v_si_invoice.remaining_amount, v_si_invoice.total);
END IF;
v_new_paid := ROUND((COALESCE(v_si_invoice.paid_amount, 0) + v_paid_in_inv_currency) * 100) / 100;
v_new_remaining := GREATEST(0,
ROUND((COALESCE(v_si_invoice.remaining_amount, v_si_invoice.total) - v_paid_in_inv_currency) * 100) / 100);
v_new_status := CASE WHEN v_new_remaining <= 0.005 THEN 'paid' ELSE 'partially_paid' END;
UPDATE public.supplier_invoices SET status = v_new_status,
paid_at = CASE WHEN v_new_status = 'paid' THEN v_now ELSE paid_at END,
paid_amount = v_new_paid, remaining_amount = v_new_remaining,
payment_journal_entry_id = v_journal_entry_id, updated_at = v_now
WHERE id = v_supplier_invoice_id;
INSERT INTO public.supplier_invoice_payments
(user_id, company_id, supplier_invoice_id, payment_date, amount, currency,
journal_entry_id, transaction_id)
VALUES
(p_user_id, p_company_id, v_supplier_invoice_id, v_tx.date, v_paid_in_inv_currency,
v_si_invoice.currency, v_journal_entry_id, p_tx_id)
RETURNING id INTO v_payment_id;
v_results := v_results || jsonb_build_array(jsonb_build_object(
'kind', 'supplier_invoice', 'supplier_invoice_id', v_supplier_invoice_id,
'payment_id', v_payment_id, 'status', v_new_status, 'paid_amount', v_new_paid,
'remaining_amount', v_new_remaining, 'amount', v_alloc_amount,
'cross_currency', v_si_invoice.currency <> v_tx.currency));
END IF;
v_alloc_index := v_alloc_index + 1;
END LOOP;
UPDATE public.transactions SET journal_entry_id = v_journal_entry_id, is_business = TRUE,
invoice_id = CASE WHEN jsonb_array_length(p_allocations) = 1 AND v_has_customer AND ABS(v_total_allocated - v_tx_abs) < 0.005
THEN (p_allocations->0->>'invoice_id')::uuid ELSE NULL END,
supplier_invoice_id = CASE WHEN jsonb_array_length(p_allocations) = 1 AND v_has_supplier AND ABS(v_total_allocated - v_tx_abs) < 0.005
THEN (p_allocations->0->>'supplier_invoice_id')::uuid ELSE NULL END,
potential_invoice_id = NULL, potential_supplier_invoice_id = NULL,
updated_at = v_now WHERE id = p_tx_id;
RETURN jsonb_build_object('ok', true, 'journal_entry_id', v_journal_entry_id,
'voucher_series', v_voucher_series, 'voucher_number', v_voucher_number,
'tx_id', p_tx_id, 'allocations', v_results, 'total_allocated', v_total_allocated,
'leftover', ROUND((v_tx_abs - v_total_allocated) * 100) / 100);
END;
$$;
NOTIFY pgrst, 'reload schema';