diff --git a/app/api/transactions/[id]/match-batch/route.ts b/app/api/transactions/[id]/match-batch/route.ts
index 988d4103..94eb309d 100644
--- a/app/api/transactions/[id]/match-batch/route.ts
+++ b/app/api/transactions/[id]/match-batch/route.ts
@@ -69,10 +69,11 @@ export const POST = withRouteContext(
const txLog = log.child({ transactionId })
+ // PR #607 round 3: p_user_id removed — RPC resolves caller from
+ // auth.uid() directly. Keeps the attack surface off the API boundary.
const { data, error } = await supabase.rpc('match_batch_allocate', {
p_tx_id: transactionId,
p_allocations: validation.data.allocations,
- p_user_id: user.id,
p_company_id: companyId,
})
diff --git a/components/transactions/MatchAllocationDialog.tsx b/components/transactions/MatchAllocationDialog.tsx
index 4cb760fc..707a330e 100644
--- a/components/transactions/MatchAllocationDialog.tsx
+++ b/components/transactions/MatchAllocationDialog.tsx
@@ -18,7 +18,7 @@ import { Skeleton } from '@/components/ui/skeleton'
import { Badge } from '@/components/ui/badge'
import { useToast } from '@/components/ui/use-toast'
import { getErrorMessage } from '@/lib/errors/get-error-message'
-import { formatCurrency, formatDate, cn } from '@/lib/utils'
+import { formatCurrency, formatDate, cn, isValidExchangeRate } from '@/lib/utils'
import { Loader2, Search, X, Plus, Check, AlertTriangle } from 'lucide-react'
import type { Invoice, Customer, SupplierInvoice, Supplier } from '@/types'
import type { TransactionWithInvoice } from './transaction-types'
@@ -35,6 +35,11 @@ interface MatchAllocationDialogProps {
* supplier invoices to the same shape so the row renderer + tally math stay
* a single code path. The `kind` discriminator drives the underlying API
* payload at submit time.
+ *
+ * `remaining` is in the invoice's own `currency` (USD, EUR, etc.).
+ * `exchangeRate` is the invoice's SEK-per-foreign-unit at invoicing time
+ * — used to compute the default SEK amount for cross-currency rows so the
+ * user doesn't have to mental-math the FX (PR #607).
*/
interface AllocationCandidate {
kind: 'customer_invoice' | 'supplier_invoice'
@@ -44,6 +49,7 @@ interface AllocationCandidate {
remaining: number
total: number
currency: string
+ exchangeRate: number | null
dueDate: string
}
@@ -120,6 +126,7 @@ export default function MatchAllocationDialog({
remaining: Number(r.remaining_amount ?? r.total ?? 0),
total: Number(r.total ?? 0),
currency: r.currency,
+ exchangeRate: r.exchange_rate != null ? Number(r.exchange_rate) : null,
dueDate: r.due_date,
})),
)
@@ -142,6 +149,7 @@ export default function MatchAllocationDialog({
remaining: Number(r.remaining_amount ?? r.total ?? 0),
total: Number(r.total ?? 0),
currency: r.currency,
+ exchangeRate: r.exchange_rate != null ? Number(r.exchange_rate) : null,
dueDate: r.due_date,
})),
)
@@ -165,14 +173,27 @@ export default function MatchAllocationDialog({
}, [open])
const txAmountAbs = transaction ? Math.abs(transaction.amount) : 0
+ const txCurrency = transaction?.currency ?? 'SEK'
+ // Each draft's `amount` is the allocation in TRANSACTION currency (SEK
+ // for a Swedish bank import). For cross-currency invoices the FX
+ // rounding lives inside per-row FX diff lines (Dr 7960 / Cr 3960) — NOT
+ // in the tolerance. So the sum must equal tx_abs exactly: anything
+ // unallocated would leave the bank line on 1930 short of the actual
+ // bank receipt and break reconciliation. (PR #607 round-1 review.)
const allocated = useMemo(() => {
return Object.values(drafts).reduce((sum, d) => sum + parseAmount(d.amount), 0)
}, [drafts])
const leftover = round2(txAmountAbs - allocated)
- const overshoot = leftover < -0.005
- const balanced = Math.abs(leftover) < 0.005 && Object.keys(drafts).length > 0
+ // 0.005 SEK matches the RPC's BATCH_AMOUNT_EXCEEDS_TX guard so the
+ // "balanced ✓" indicator never lies to the user about what the server
+ // will accept.
+ const TOLERANCE = 0.005
+ const overshoot = leftover < -TOLERANCE
+ const balanced =
+ Math.abs(leftover) < TOLERANCE && Object.keys(drafts).length > 0
+ const undershoot = leftover > TOLERANCE
const filteredCandidates = useMemo(() => {
const selectedIds = new Set(Object.keys(drafts))
@@ -194,7 +215,29 @@ export default function MatchAllocationDialog({
setDrafts((prev) => {
if (prev[candidate.id]) return prev
const remainingTxBudget = Math.max(0, round2(txAmountAbs - allocated))
- const defaultAmount = Math.min(candidate.remaining, remainingTxBudget)
+ const sameCurrency = candidate.currency === txCurrency
+
+ // Same-currency: partial allowed, default to min(remaining, budget).
+ // Cross-currency: full-payment-only, default to booked SEK (rate
+ // sanity-checked). NOT capped to remainingTxBudget — the cross-
+ // currency RPC guard requires the amount to be within ±10% of
+ // booked_sek, so capping a USD invoice's default at the leftover
+ // budget would silently trigger BATCH_FX_DEVIATION_TOO_LARGE on
+ // submit. Instead, let the row default to the right amount and
+ // the user re-balances the other rows to fit. PR #607 review fix.
+ let defaultAmount: number
+ if (sameCurrency) {
+ defaultAmount = Math.min(candidate.remaining, remainingTxBudget)
+ } else if (isValidExchangeRate(candidate.exchangeRate)) {
+ defaultAmount = round2(candidate.remaining * candidate.exchangeRate)
+ } else {
+ // No (or out-of-range) FX rate. Leave the amount blank rather
+ // than guessing a misleading default; the user must enter the
+ // SEK amount the bank converted to manually. Blocked from
+ // confirm via the per-row warning below.
+ defaultAmount = 0
+ }
+
return {
...prev,
[candidate.id]: {
@@ -222,12 +265,10 @@ export default function MatchAllocationDialog({
async function handleConfirm() {
if (!transaction) return
- if (!balanced && !overshoot) {
- // Allow undershoot — the tx keeps its leftover unallocated. But reject
- // a no-allocation submit.
- if (Object.keys(drafts).length === 0) return
- }
- if (overshoot) return
+ // PR #607 round-1 review: require balanced. Undershoot is no longer
+ // allowed because it leaves the bank line short of tx_abs and breaks
+ // reconciliation.
+ if (!balanced || overshoot) return
setSubmitting(true)
try {
@@ -384,24 +425,43 @@ export default function MatchAllocationDialog({
{isSelected ? (
-
-
setDraftAmount(c.id, e.target.value)}
- className="h-9 w-28 font-mono text-right tabular-nums"
- aria-label={t('amount_input_aria', { label: c.label })}
- />
-
removeAllocation(c.id)}
- aria-label={t('remove_aria', { label: c.label })}
- >
-
-
+
+
+ setDraftAmount(c.id, e.target.value)}
+ className="h-9 w-28 font-mono text-right tabular-nums"
+ aria-label={t('amount_input_aria', { label: c.label })}
+ />
+ removeAllocation(c.id)}
+ aria-label={t('remove_aria', { label: c.label })}
+ >
+
+
+
+ {/* FX hint — appears only for cross-currency rows
+ so the user can see what their tx-currency
+ input translates to in invoice currency.
+ When the rate is missing or out of range, we
+ warn instead of silently defaulting to a
+ misleading number. PR #607 round-1 review. */}
+ {c.currency !== txCurrency && (
+ isValidExchangeRate(c.exchangeRate) ? (
+
+ ≈ {formatCurrency(parseAmount(draft.amount) / c.exchangeRate, c.currency)}
+
+ ) : (
+
+ {t('fx_rate_missing_warning', { currency: c.currency })}
+
+ )
+ )}
) : (
{t('balanced_message')}
- ) : leftover > 0.005 && Object.keys(drafts).length > 0 ? (
-
- {t('leftover_note', {
- amount: formatCurrency(leftover, transaction.currency),
- })}
-
+ ) : undershoot && Object.keys(drafts).length > 0 ? (
+ // Undershoot is now a blocking state — the JE's 1930 line
+ // must equal the bank's actual receipt or reconciliation
+ // breaks. The user must allocate the full amount or remove
+ // selections. PR #607 round-1 review fix.
+
+
+
+ {t('undershoot_warning', {
+ amount: formatCurrency(leftover, transaction.currency),
+ })}
+
+
) : null}
@@ -466,7 +533,10 @@ export default function MatchAllocationDialog({
{submitting && }
{t('confirm')}
diff --git a/lib/errors/structured-errors.ts b/lib/errors/structured-errors.ts
index 8288a70e..f4067015 100644
--- a/lib/errors/structured-errors.ts
+++ b/lib/errors/structured-errors.ts
@@ -1887,6 +1887,13 @@ const MATCH_BATCH: Record = {
'Summan av fördelningarna är större än transaktionens belopp.',
message_en: 'Sum of allocations exceeds the transaction amount.',
},
+ BATCH_AMOUNT_BELOW_TX: {
+ httpStatus: 400,
+ message_sv:
+ 'Hela transaktionen måste fördelas. Lägg till fler fakturor eller höj något belopp så att summan motsvarar bankhändelsen.',
+ message_en:
+ 'The full transaction amount must be allocated. Add more invoices or raise an amount so the sum matches the bank movement.',
+ },
BATCH_MIXED_KINDS_UNSUPPORTED: {
httpStatus: 400,
message_sv:
@@ -1908,6 +1915,20 @@ const MATCH_BATCH: Record = {
message_en:
'Invoice currency does not match the transaction currency. Same-currency only in v1.',
},
+ BATCH_FX_RATE_MISSING: {
+ httpStatus: 400,
+ message_sv:
+ 'Fakturan i annan valuta saknar växelkurs. Komplettera fakturans exchange_rate innan du fördelar.',
+ message_en:
+ 'The foreign-currency invoice has no exchange rate on file. Complete invoice.exchange_rate before allocating.',
+ },
+ BATCH_FX_DEVIATION_TOO_LARGE: {
+ httpStatus: 400,
+ message_sv:
+ 'Beloppet du angav avviker mer än 10 % från fakturans bokförda värde. Kontrollera att du fyllt i bankbeloppet i transaktionens valuta.',
+ message_en:
+ 'The amount you entered deviates more than 10% from the invoice\'s booked SEK value. Check that you entered the bank-side amount in the transaction\'s currency.',
+ },
BATCH_NO_FISCAL_PERIOD: {
httpStatus: 400,
message_sv:
diff --git a/lib/utils.ts b/lib/utils.ts
index ebab9253..c0bf535a 100644
--- a/lib/utils.ts
+++ b/lib/utils.ts
@@ -89,3 +89,10 @@ export function generateInvoiceNumber(): string {
const random = Math.floor(Math.random() * 10000).toString().padStart(4, '0')
return `${year}-${random}`
}
+
+// Shared FX-rate validator — keeps UI, RPC (>= 100000 / <= 0), and the
+// invoices/supplier_invoices CHECK constraints in sync. Single source
+// of truth for the 0 < rate < 100000 bound.
+export function isValidExchangeRate(rate: number | null | undefined): rate is number {
+ return rate != null && rate > 0 && rate < 100000
+}
diff --git a/messages/en.json b/messages/en.json
index 628ae9c2..1c55185f 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -1867,7 +1867,8 @@
"allocated_label": "Allocated",
"balanced_message": "Amounts match — ready to confirm.",
"overshoot_warning": "Allocation exceeds the transaction by {excess}. Lower an amount or remove an invoice.",
- "leftover_note": "{amount} left to allocate (leave unallocated or add more invoices).",
+ "undershoot_warning": "{amount} left to allocate — the full transaction must be covered before you can confirm. Add more invoices or adjust amounts.",
+ "fx_rate_missing_warning": "No exchange rate on file for {currency} — enter the SEK amount manually.",
"error_no_allocations_title": "No allocations",
"error_no_allocations_description": "Pick at least one invoice to allocate the payment to.",
"error_submit_title": "Allocation could not be saved",
diff --git a/messages/sv.json b/messages/sv.json
index c42e0ce1..bd44a992 100644
--- a/messages/sv.json
+++ b/messages/sv.json
@@ -1867,7 +1867,8 @@
"allocated_label": "Tilldelat",
"balanced_message": "Beloppen stämmer — klart att bekräfta.",
"overshoot_warning": "Fördelningen överskrider transaktionen med {excess}. Sänk något belopp eller ta bort en faktura.",
- "leftover_note": "{amount} kvar att fördela (lämna oallokerat eller lägg till fler fakturor).",
+ "undershoot_warning": "{amount} kvar att fördela — hela transaktionen måste täckas innan du kan bekräfta. Lägg till fler fakturor eller justera beloppen.",
+ "fx_rate_missing_warning": "Saknar växelkurs för {currency} — ange beloppet i kronor manuellt.",
"error_no_allocations_title": "Inga fördelningar",
"error_no_allocations_description": "Välj minst en faktura att fördela betalningen på.",
"error_submit_title": "Fördelningen kunde inte sparas",
diff --git a/supabase/migrations/20260531120000_match_batch_allocate_cross_currency.sql b/supabase/migrations/20260531120000_match_batch_allocate_cross_currency.sql
new file mode 100644
index 00000000..f2c0c141
--- /dev/null
+++ b/supabase/migrations/20260531120000_match_batch_allocate_cross_currency.sql
@@ -0,0 +1,493 @@
+-- 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';
diff --git a/supabase/migrations/20260601120000_match_batch_allocate_round1_fixes.sql b/supabase/migrations/20260601120000_match_batch_allocate_round1_fixes.sql
new file mode 100644
index 00000000..c4b6c23d
--- /dev/null
+++ b/supabase/migrations/20260601120000_match_batch_allocate_round1_fixes.sql
@@ -0,0 +1,505 @@
+-- PR #607 review round-1 fixes for match_batch_allocate.
+--
+-- Five tightenings, all motivated by review findings on the prior patch
+-- (20260531120000_match_batch_allocate_cross_currency.sql):
+--
+-- 1. Strict undershoot rejection. Previously the RPC accepted any
+-- v_total_allocated <= v_tx_abs, which meant the journal entry's
+-- bank line could come up short of the actual bank receipt and
+-- silently break reconciliation. We now require
+-- ABS(v_total_allocated - v_tx_abs) <= 0.005.
+-- The UI already enforces "fully allocated" — this matches it so
+-- the server can't be coaxed into the same broken state by a
+-- direct API caller. Returns new code BATCH_AMOUNT_BELOW_TX.
+--
+-- 2. Bank line uses v_tx_abs (not v_total_allocated). With strict sum
+-- check (#1) the two values are equal within rounding, but using
+-- v_tx_abs makes the intent legible — the bank line IS the bank
+-- receipt, full stop. Per-row FX diff lines absorb the rounding.
+--
+-- 3. Defense-in-depth company_id filter on re-queries in the line-
+-- build and payment passes. The validation pass already locked
+-- the rows with the filter, so this is paranoia, not correctness
+-- — but it costs nothing and closes the door on a future hand-
+-- crafted attack that swaps allocation.invoice_id between passes.
+-- (Greptile V8.2.1.)
+--
+-- 4. Drop the v_booked_sek temp-aliasing in the line-build pass.
+-- The previous code reused v_booked_sek as a scratch var for
+-- invoice.total, which was a documented foot-gun and made the
+-- cross-currency branch hard to read. Adds a dedicated v_inv_total.
+-- (Greptile PI1.3.)
+--
+-- 5. Truncate invoice_number in line_description to 32 chars. An
+-- adversarial 200-char invoice_number would push the description
+-- past the column's text length and break verification on SIE
+-- export readers that assume sensible field widths. Truncation
+-- mirrors what `padEnd(40)` would do downstream.
+-- (Greptile V1.2.5.)
+--
+-- Everything else stays byte-identical: caller membership check, dedupe,
+-- deadlock-stable ORDER BY locking, period resolution, payment-row
+-- inserts, direction-mismatch guards, FX deviation guard.
+
+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;
+ v_inv_remaining numeric;
+ v_inv_currency text;
+ v_inv_fx_rate numeric;
+ v_inv_total numeric; -- invoice.total (was aliased to v_booked_sek before — review fix #4)
+ v_booked_sek numeric; -- SEK on 1510/2440 at booking time = inv_remaining × rate
+ v_fx_diff numeric; -- bookedSek - allocation.amount (signed)
+ v_paid_in_inv_currency numeric;
+ v_inv_number_short text; -- truncated invoice_number for description (review fix #5)
+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;
+
+ 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
+ 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
+ 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;
+
+ -- Review fix #1: strict sum check on BOTH sides. Was previously only
+ -- overshoot. Undershoot is now a server-side reject so a direct API
+ -- caller can't sneak past the UI's balanced-only confirm.
+ 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_total_allocated < v_tx_abs - 0.005 THEN
+ RETURN jsonb_build_object('ok', false, 'code', 'BATCH_AMOUNT_BELOW_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. Re-queries the locked invoice row WITH the company_id
+ -- filter (review fix #3) and uses a dedicated v_inv_total var instead of
+ -- aliasing v_booked_sek (review fix #4).
+ 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_inv_total
+ FROM public.invoices
+ WHERE id = v_invoice_id AND company_id = p_company_id;
+ v_inv_remaining := COALESCE(v_inv_remaining, v_inv_total);
+
+ -- Review fix #5: truncate invoice_number to 32 chars in description.
+ v_inv_number_short := LEFT(COALESCE(v_invoice_number, ''), 32);
+
+ 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, '1510', 0, v_alloc_amount, v_tx.currency, v_line_sort_order,
+ 'Faktura ' || v_inv_number_short);
+ 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, '1510', 0, v_booked_sek, v_tx.currency, v_line_sort_order,
+ 'Faktura ' || v_inv_number_short || ' (' || 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
+ 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 ' || v_inv_number_short);
+ 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, '3960', 0, ABS(v_fx_diff), v_tx.currency, v_line_sort_order,
+ 'Valutakursvinst ' || v_inv_number_short);
+ 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_inv_total
+ FROM public.supplier_invoices si LEFT JOIN public.suppliers s ON s.id = si.supplier_id
+ WHERE si.id = v_supplier_invoice_id AND si.company_id = p_company_id;
+ v_inv_remaining := COALESCE(v_inv_remaining, v_inv_total);
+ v_inv_number_short := LEFT(COALESCE(v_supplier_invoice_number, ''), 32);
+
+ 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, '') || ' - ' || v_inv_number_short));
+ 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, '') || ' - ' || v_inv_number_short
+ || ' (' || 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
+ 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 ' || v_inv_number_short);
+ 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, '7960', ABS(v_fx_diff), 0, v_tx.currency, v_line_sort_order,
+ 'Valutakursförlust ' || v_inv_number_short);
+ 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;
+
+ -- Review fix #2: bank line uses v_tx_abs (the actual bank receipt).
+ -- The journal balances because per-row FX diff lines absorbed the
+ -- rounding between booked_sek and alloc_amount on cross-currency
+ -- rows, and the strict sum check guarantees same-currency rows
+ -- already total to v_tx_abs.
+ 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_tx_abs, 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_tx_abs, v_tx.currency, v_line_sort_order,
+ 'Utbetalning ' || v_tx.date);
+ END IF;
+
+ SELECT voucher_number INTO v_voucher_number FROM public.commit_journal_entry(p_company_id, v_journal_entry_id);
+
+ v_alloc_index := 0;
+ FOR v_allocation IN
+ SELECT value FROM jsonb_array_elements(p_allocations) AS t(value)
+ ORDER BY COALESCE(value->>'invoice_id', value->>'supplier_invoice_id', '')
+ LOOP
+ v_alloc_amount := (v_allocation->>'amount')::numeric;
+
+ IF v_has_customer THEN
+ v_invoice_id := (v_allocation->>'invoice_id')::uuid;
+ -- Review fix #3: re-query with company_id filter.
+ SELECT * INTO v_invoice FROM public.invoices
+ WHERE id = v_invoice_id AND company_id = p_company_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 AND company_id = p_company_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 AND company_id = p_company_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 AND company_id = p_company_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 AND company_id = p_company_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', 0);
+END;
+$$;
+
+NOTIFY pgrst, 'reload schema';
diff --git a/supabase/migrations/20260601121000_match_batch_allocate_round2_fixes.sql b/supabase/migrations/20260601121000_match_batch_allocate_round2_fixes.sql
new file mode 100644
index 00000000..4c5f5724
--- /dev/null
+++ b/supabase/migrations/20260601121000_match_batch_allocate_round2_fixes.sql
@@ -0,0 +1,494 @@
+-- PR #607 review round-2 fixes for match_batch_allocate.
+--
+-- Four fixes from the compliance-swarm + swedish-compliance review on
+-- top of round 1 (20260601120000_match_batch_allocate_round1_fixes.sql):
+--
+-- 1. CC6.3 / HIGH — caller user_id verification. p_user_id was caller-
+-- supplied and written directly into journal_entries.user_id and
+-- payment-row user_id without verifying it equals auth.uid(). The
+-- company-membership check covered the company, but not the user
+-- attribution — a member could write entries attributed to any
+-- auth user. Two-layer fix:
+-- a) Reject with BATCH_UNAUTHORIZED if p_user_id <> auth.uid().
+-- b) Use auth.uid() in all writes (belt-and-suspenders so even
+-- if the guard is somehow bypassed, the writes still resolve
+-- the right user).
+--
+-- 2. A.8.28 / MEDIUM — server-side FX rate upper bound. The UI guards
+-- against 0 < rate < 100000, but the RPC only checked > 0. Add the
+-- same upper bound to BATCH_FX_RATE_MISSING (intentionally reusing
+-- the existing code — rate=200000 is just as unusable as NULL).
+--
+-- 3. V1.2.5 / LOW — truncate v_tx.date when embedding in the bank
+-- line_description. The column is date-typed so the format is
+-- already bounded, but consistency with the invoice_number
+-- truncation in round 1 is worth the 10 chars.
+--
+-- 4. Symmetry — populate supplier_invoice_payments.exchange_rate
+-- (the column exists; the previous INSERT omitted it). Customer
+-- side already populated invoice_payments.exchange_rate. Swedish-
+-- compliance flagged this as a traceability gap on AP rörelseskulder.
+--
+-- Everything else stays byte-identical from round 1.
+
+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_tx_date_short text;
+ 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;
+ v_inv_remaining numeric;
+ v_inv_currency text;
+ v_inv_fx_rate numeric;
+ v_inv_total numeric;
+ v_booked_sek numeric;
+ v_fx_diff numeric;
+ v_paid_in_inv_currency numeric;
+ v_inv_number_short text;
+ v_caller uuid := auth.uid(); -- round-2 fix #1: cache caller, use everywhere
+BEGIN
+ -- Round-2 fix #1 (CC6.3): membership AND caller-attribution check.
+ IF NOT EXISTS (
+ SELECT 1 FROM public.company_members
+ WHERE user_id = v_caller AND company_id = p_company_id
+ ) THEN
+ RETURN jsonb_build_object('ok', false, 'code', 'BATCH_UNAUTHORIZED');
+ END IF;
+ IF p_user_id IS DISTINCT FROM v_caller THEN
+ RETURN jsonb_build_object('ok', false, 'code', 'BATCH_UNAUTHORIZED',
+ 'details', jsonb_build_object('reason', 'user_attribution_mismatch'));
+ 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);
+ -- Round-2 fix #3 (V1.2.5): bound the date string explicitly.
+ v_tx_date_short := LEFT(v_tx.date::text, 10);
+
+ IF jsonb_typeof(p_allocations) IS DISTINCT FROM 'array' OR jsonb_array_length(p_allocations) = 0 THEN
+ RETURN jsonb_build_object('ok', false, 'code', 'BATCH_NO_ALLOCATIONS');
+ END IF;
+
+ FOR v_allocation IN
+ SELECT value FROM jsonb_array_elements(p_allocations) AS t(value)
+ ORDER BY COALESCE(value->>'invoice_id', value->>'supplier_invoice_id', '')
+ LOOP
+ v_kind := v_allocation->>'kind';
+ v_alloc_amount := (v_allocation->>'amount')::numeric;
+ v_target_id := COALESCE(v_allocation->>'invoice_id', v_allocation->>'supplier_invoice_id');
+
+ IF v_alloc_amount IS NULL OR v_alloc_amount <= 0 THEN
+ RETURN jsonb_build_object('ok', false, 'code', 'BATCH_INVALID_AMOUNT',
+ 'details', jsonb_build_object('index', v_alloc_index, 'amount', v_alloc_amount));
+ END IF;
+ IF v_target_id IS NOT NULL AND v_target_id = ANY(v_seen_ids) THEN
+ RETURN jsonb_build_object('ok', false, 'code', 'BATCH_DUPLICATE_ALLOCATION',
+ 'details', jsonb_build_object('id', v_target_id, 'index', v_alloc_index));
+ END IF;
+ IF v_target_id IS NOT NULL THEN v_seen_ids := array_append(v_seen_ids, v_target_id); END IF;
+ v_total_allocated := v_total_allocated + v_alloc_amount;
+
+ IF v_kind = 'customer_invoice' THEN
+ v_has_customer := true;
+ v_invoice_id := (v_allocation->>'invoice_id')::uuid;
+ SELECT * INTO v_invoice FROM public.invoices
+ WHERE id = v_invoice_id AND company_id = p_company_id FOR UPDATE;
+ IF NOT FOUND THEN
+ RETURN jsonb_build_object('ok', false, 'code', 'BATCH_INVOICE_NOT_FOUND',
+ 'details', jsonb_build_object('index', v_alloc_index, 'invoice_id', v_invoice_id));
+ END IF;
+ IF v_invoice.status NOT IN ('sent', 'overdue', 'partially_paid') THEN
+ RETURN jsonb_build_object('ok', false, 'code', 'BATCH_INVOICE_NOT_OPEN',
+ 'details', jsonb_build_object('index', v_alloc_index, 'invoice_id', v_invoice_id, 'status', v_invoice.status));
+ END IF;
+
+ 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
+ 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
+ -- Round-2 fix #2 (A.8.28): bounded FX rate check matching the UI.
+ IF v_inv_fx_rate IS NULL OR v_inv_fx_rate <= 0 OR v_inv_fx_rate >= 100000 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 OR v_inv_fx_rate >= 100000 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_total_allocated < v_tx_abs - 0.005 THEN
+ RETURN jsonb_build_object('ok', false, 'code', 'BATCH_AMOUNT_BELOW_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_short ELSE 'Samlingsbetalning ' || v_tx_date_short END;
+ v_source_type := CASE WHEN v_has_customer THEN 'invoice_paid' ELSE 'supplier_invoice_paid' END;
+
+ -- Round-2 fix #1: write v_caller, not p_user_id.
+ 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, v_caller, p_company_id, v_fiscal_period_id, 0, v_voucher_series,
+ v_tx.date, v_entry_description, v_source_type, 'draft');
+
+ v_alloc_index := 0;
+ FOR v_allocation IN
+ SELECT value FROM jsonb_array_elements(p_allocations) AS t(value)
+ ORDER BY COALESCE(value->>'invoice_id', value->>'supplier_invoice_id', '')
+ LOOP
+ v_alloc_amount := (v_allocation->>'amount')::numeric;
+
+ IF v_has_customer THEN
+ v_invoice_id := (v_allocation->>'invoice_id')::uuid;
+ SELECT invoice_number, currency, exchange_rate, remaining_amount, total
+ INTO v_invoice_number, v_inv_currency, v_inv_fx_rate, v_inv_remaining, v_inv_total
+ FROM public.invoices
+ WHERE id = v_invoice_id AND company_id = p_company_id;
+ v_inv_remaining := COALESCE(v_inv_remaining, v_inv_total);
+ v_inv_number_short := LEFT(COALESCE(v_invoice_number, ''), 32);
+
+ 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, '1510', 0, v_alloc_amount, v_tx.currency, v_line_sort_order,
+ 'Faktura ' || v_inv_number_short);
+ 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, '1510', 0, v_booked_sek, v_tx.currency, v_line_sort_order,
+ 'Faktura ' || v_inv_number_short || ' (' || 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
+ 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 ' || v_inv_number_short);
+ 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, '3960', 0, ABS(v_fx_diff), v_tx.currency, v_line_sort_order,
+ 'Valutakursvinst ' || v_inv_number_short);
+ 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_inv_total
+ FROM public.supplier_invoices si LEFT JOIN public.suppliers s ON s.id = si.supplier_id
+ WHERE si.id = v_supplier_invoice_id AND si.company_id = p_company_id;
+ v_inv_remaining := COALESCE(v_inv_remaining, v_inv_total);
+ v_inv_number_short := LEFT(COALESCE(v_supplier_invoice_number, ''), 32);
+
+ 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, '') || ' - ' || v_inv_number_short));
+ 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, '') || ' - ' || v_inv_number_short
+ || ' (' || 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
+ 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 ' || v_inv_number_short);
+ 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, '7960', ABS(v_fx_diff), 0, v_tx.currency, v_line_sort_order,
+ 'Valutakursförlust ' || v_inv_number_short);
+ 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 — uses v_tx_date_short (round-2 fix #3).
+ 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_tx_abs, 0, v_tx.currency, v_line_sort_order,
+ 'Inbetalning ' || v_tx_date_short);
+ 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_tx_abs, v_tx.currency, v_line_sort_order,
+ 'Utbetalning ' || v_tx_date_short);
+ END IF;
+
+ SELECT voucher_number INTO v_voucher_number FROM public.commit_journal_entry(p_company_id, v_journal_entry_id);
+
+ v_alloc_index := 0;
+ FOR v_allocation IN
+ SELECT value FROM jsonb_array_elements(p_allocations) AS t(value)
+ ORDER BY COALESCE(value->>'invoice_id', value->>'supplier_invoice_id', '')
+ LOOP
+ v_alloc_amount := (v_allocation->>'amount')::numeric;
+
+ IF v_has_customer THEN
+ v_invoice_id := (v_allocation->>'invoice_id')::uuid;
+ SELECT * INTO v_invoice FROM public.invoices
+ WHERE id = v_invoice_id AND company_id = p_company_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 AND company_id = p_company_id;
+
+ INSERT INTO public.invoice_payments
+ (user_id, company_id, invoice_id, payment_date, amount, currency, exchange_rate,
+ journal_entry_id, transaction_id)
+ VALUES
+ (v_caller, 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 AND company_id = p_company_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 AND company_id = p_company_id;
+
+ -- Round-2 fix #4: populate exchange_rate (column existed, was omitted).
+ INSERT INTO public.supplier_invoice_payments
+ (user_id, company_id, supplier_invoice_id, payment_date, amount, currency, exchange_rate,
+ journal_entry_id, transaction_id)
+ VALUES
+ (v_caller, p_company_id, v_supplier_invoice_id, v_tx.date, v_paid_in_inv_currency,
+ v_si_invoice.currency, v_si_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', '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 AND company_id = p_company_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', 0);
+END;
+$$;
+
+NOTIFY pgrst, 'reload schema';
diff --git a/supabase/migrations/20260601122000_match_batch_allocate_round3_fixes.sql b/supabase/migrations/20260601122000_match_batch_allocate_round3_fixes.sql
new file mode 100644
index 00000000..327b7f85
--- /dev/null
+++ b/supabase/migrations/20260601122000_match_batch_allocate_round3_fixes.sql
@@ -0,0 +1,525 @@
+-- PR #607 review round-3 fixes.
+--
+-- Genuine findings on round 2 (not historical-migration retro):
+--
+-- 1. V4.5 — drop p_user_id from the RPC signature entirely. Round 2
+-- added a guard rejecting mismatched values, but the parameter
+-- itself is the attack surface; removing it eliminates the
+-- possibility of attribute confusion at the API boundary.
+--
+-- 2. V2.2 — DB-level CHECK constraint on invoices.exchange_rate and
+-- supplier_invoices.exchange_rate. UI and RPC both apply the
+-- 0 < rate < 100000 bound; the schema is the third (and most
+-- authoritative) layer. We use NOT VALID on the ADD CONSTRAINT
+-- then VALIDATE to keep the migration fast on existing rows
+-- (and to make legacy rows with NULL pass — they're already
+-- handled by the RPC guard).
+--
+-- 3. Swedish-compliance traceability — payment row needs to retain
+-- the actual payment-day exchange rate, not just the invoicing
+-- rate, so FX diffs are reconstructible from the payment record
+-- alone (BFL 7 kap behandlingshistorik, BFNAR 2013:2 kap 8). Adds
+-- payment_exchange_rate column to both invoice_payments and
+-- supplier_invoice_payments; the RPC populates it as
+-- v_alloc_amount / v_inv_remaining for cross-currency rows
+-- (= same as invoice exchange_rate for same-currency rows).
+--
+-- Same-currency invariants unchanged. Function body byte-identical to
+-- round 2 except the parameter list and the new payment_exchange_rate
+-- writes.
+
+-- 1. CHECK constraints on invoice exchange rates (V2.2)
+ALTER TABLE public.invoices DROP CONSTRAINT IF EXISTS invoices_exchange_rate_check;
+ALTER TABLE public.invoices ADD CONSTRAINT invoices_exchange_rate_check
+ CHECK (exchange_rate IS NULL OR (exchange_rate > 0 AND exchange_rate < 100000)) NOT VALID;
+ALTER TABLE public.invoices VALIDATE CONSTRAINT invoices_exchange_rate_check;
+
+ALTER TABLE public.supplier_invoices DROP CONSTRAINT IF EXISTS supplier_invoices_exchange_rate_check;
+ALTER TABLE public.supplier_invoices ADD CONSTRAINT supplier_invoices_exchange_rate_check
+ CHECK (exchange_rate IS NULL OR (exchange_rate > 0 AND exchange_rate < 100000)) NOT VALID;
+ALTER TABLE public.supplier_invoices VALIDATE CONSTRAINT supplier_invoices_exchange_rate_check;
+
+-- 2. payment_exchange_rate column on both payment tables (traceability)
+ALTER TABLE public.invoice_payments
+ ADD COLUMN IF NOT EXISTS payment_exchange_rate numeric
+ CHECK (payment_exchange_rate IS NULL OR (payment_exchange_rate > 0 AND payment_exchange_rate < 100000));
+
+ALTER TABLE public.supplier_invoice_payments
+ ADD COLUMN IF NOT EXISTS payment_exchange_rate numeric
+ CHECK (payment_exchange_rate IS NULL OR (payment_exchange_rate > 0 AND payment_exchange_rate < 100000));
+
+COMMENT ON COLUMN public.invoice_payments.payment_exchange_rate IS
+ 'Effective payment-day SEK/foreign rate, derived from v_alloc_amount / v_inv_remaining. Distinct from exchange_rate (invoicing rate). Populated by match_batch_allocate for cross-currency rows; NULL for same-currency.';
+COMMENT ON COLUMN public.supplier_invoice_payments.payment_exchange_rate IS
+ 'Effective payment-day SEK/foreign rate, derived from v_alloc_amount / v_inv_remaining. Distinct from exchange_rate (invoicing rate). Populated by match_batch_allocate for cross-currency rows; NULL for same-currency.';
+
+-- 3. Drop the 4-arg signature and re-create with 3 args (V4.5).
+DROP FUNCTION IF EXISTS public.match_batch_allocate(uuid, jsonb, uuid, uuid);
+
+CREATE OR REPLACE FUNCTION public.match_batch_allocate(
+ p_tx_id uuid,
+ p_allocations jsonb,
+ 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_tx_date_short text;
+ 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;
+ v_inv_remaining numeric;
+ v_inv_currency text;
+ v_inv_fx_rate numeric;
+ v_inv_total numeric;
+ v_booked_sek numeric;
+ v_fx_diff numeric;
+ v_paid_in_inv_currency numeric;
+ v_payment_rate numeric; -- round-3 (swedish-compliance traceability)
+ v_inv_number_short text;
+ v_caller uuid := auth.uid();
+BEGIN
+ IF v_caller IS NULL THEN
+ RETURN jsonb_build_object('ok', false, 'code', 'BATCH_UNAUTHORIZED');
+ END IF;
+ IF NOT EXISTS (
+ SELECT 1 FROM public.company_members
+ WHERE user_id = v_caller 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);
+ v_tx_date_short := LEFT(v_tx.date::text, 10);
+
+ IF jsonb_typeof(p_allocations) IS DISTINCT FROM 'array' OR jsonb_array_length(p_allocations) = 0 THEN
+ RETURN jsonb_build_object('ok', false, 'code', 'BATCH_NO_ALLOCATIONS');
+ END IF;
+
+ FOR v_allocation IN
+ SELECT value FROM jsonb_array_elements(p_allocations) AS t(value)
+ ORDER BY COALESCE(value->>'invoice_id', value->>'supplier_invoice_id', '')
+ LOOP
+ v_kind := v_allocation->>'kind';
+ v_alloc_amount := (v_allocation->>'amount')::numeric;
+ v_target_id := COALESCE(v_allocation->>'invoice_id', v_allocation->>'supplier_invoice_id');
+
+ IF v_alloc_amount IS NULL OR v_alloc_amount <= 0 THEN
+ RETURN jsonb_build_object('ok', false, 'code', 'BATCH_INVALID_AMOUNT',
+ 'details', jsonb_build_object('index', v_alloc_index, 'amount', v_alloc_amount));
+ END IF;
+ IF v_target_id IS NOT NULL AND v_target_id = ANY(v_seen_ids) THEN
+ RETURN jsonb_build_object('ok', false, 'code', 'BATCH_DUPLICATE_ALLOCATION',
+ 'details', jsonb_build_object('id', v_target_id, 'index', v_alloc_index));
+ END IF;
+ IF v_target_id IS NOT NULL THEN v_seen_ids := array_append(v_seen_ids, v_target_id); END IF;
+ v_total_allocated := v_total_allocated + v_alloc_amount;
+
+ IF v_kind = 'customer_invoice' THEN
+ v_has_customer := true;
+ v_invoice_id := (v_allocation->>'invoice_id')::uuid;
+ SELECT * INTO v_invoice FROM public.invoices
+ WHERE id = v_invoice_id AND company_id = p_company_id FOR UPDATE;
+ IF NOT FOUND THEN
+ RETURN jsonb_build_object('ok', false, 'code', 'BATCH_INVOICE_NOT_FOUND',
+ 'details', jsonb_build_object('index', v_alloc_index, 'invoice_id', v_invoice_id));
+ END IF;
+ IF v_invoice.status NOT IN ('sent', 'overdue', 'partially_paid') THEN
+ RETURN jsonb_build_object('ok', false, 'code', 'BATCH_INVOICE_NOT_OPEN',
+ 'details', jsonb_build_object('index', v_alloc_index, 'invoice_id', v_invoice_id, 'status', v_invoice.status));
+ END IF;
+
+ 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
+ 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
+ IF v_inv_fx_rate IS NULL OR v_inv_fx_rate <= 0 OR v_inv_fx_rate >= 100000 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 OR v_inv_fx_rate >= 100000 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_total_allocated < v_tx_abs - 0.005 THEN
+ RETURN jsonb_build_object('ok', false, 'code', 'BATCH_AMOUNT_BELOW_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_short ELSE 'Samlingsbetalning ' || v_tx_date_short 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, v_caller, p_company_id, v_fiscal_period_id, 0, v_voucher_series,
+ v_tx.date, v_entry_description, v_source_type, 'draft');
+
+ v_alloc_index := 0;
+ FOR v_allocation IN
+ SELECT value FROM jsonb_array_elements(p_allocations) AS t(value)
+ ORDER BY COALESCE(value->>'invoice_id', value->>'supplier_invoice_id', '')
+ LOOP
+ v_alloc_amount := (v_allocation->>'amount')::numeric;
+
+ IF v_has_customer THEN
+ v_invoice_id := (v_allocation->>'invoice_id')::uuid;
+ SELECT invoice_number, currency, exchange_rate, remaining_amount, total
+ INTO v_invoice_number, v_inv_currency, v_inv_fx_rate, v_inv_remaining, v_inv_total
+ FROM public.invoices
+ WHERE id = v_invoice_id AND company_id = p_company_id;
+ v_inv_remaining := COALESCE(v_inv_remaining, v_inv_total);
+ v_inv_number_short := LEFT(COALESCE(v_invoice_number, ''), 32);
+
+ 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, '1510', 0, v_alloc_amount, v_tx.currency, v_line_sort_order,
+ 'Faktura ' || v_inv_number_short);
+ 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, '1510', 0, v_booked_sek, v_tx.currency, v_line_sort_order,
+ 'Faktura ' || v_inv_number_short || ' (' || 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
+ 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 ' || v_inv_number_short);
+ 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, '3960', 0, ABS(v_fx_diff), v_tx.currency, v_line_sort_order,
+ 'Valutakursvinst ' || v_inv_number_short);
+ 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_inv_total
+ FROM public.supplier_invoices si LEFT JOIN public.suppliers s ON s.id = si.supplier_id
+ WHERE si.id = v_supplier_invoice_id AND si.company_id = p_company_id;
+ v_inv_remaining := COALESCE(v_inv_remaining, v_inv_total);
+ v_inv_number_short := LEFT(COALESCE(v_supplier_invoice_number, ''), 32);
+
+ 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, '') || ' - ' || v_inv_number_short));
+ 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, '') || ' - ' || v_inv_number_short
+ || ' (' || 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
+ 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 ' || v_inv_number_short);
+ 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, '7960', ABS(v_fx_diff), 0, v_tx.currency, v_line_sort_order,
+ 'Valutakursförlust ' || v_inv_number_short);
+ 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;
+
+ 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_tx_abs, 0, v_tx.currency, v_line_sort_order,
+ 'Inbetalning ' || v_tx_date_short);
+ 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_tx_abs, v_tx.currency, v_line_sort_order,
+ 'Utbetalning ' || v_tx_date_short);
+ END IF;
+
+ SELECT voucher_number INTO v_voucher_number FROM public.commit_journal_entry(p_company_id, v_journal_entry_id);
+
+ v_alloc_index := 0;
+ FOR v_allocation IN
+ SELECT value FROM jsonb_array_elements(p_allocations) AS t(value)
+ ORDER BY COALESCE(value->>'invoice_id', value->>'supplier_invoice_id', '')
+ LOOP
+ v_alloc_amount := (v_allocation->>'amount')::numeric;
+
+ IF v_has_customer THEN
+ v_invoice_id := (v_allocation->>'invoice_id')::uuid;
+ SELECT * INTO v_invoice FROM public.invoices
+ WHERE id = v_invoice_id AND company_id = p_company_id;
+
+ IF v_invoice.currency = v_tx.currency THEN
+ v_paid_in_inv_currency := v_alloc_amount;
+ v_payment_rate := NULL; -- same-currency: no FX context
+ ELSE
+ v_paid_in_inv_currency := COALESCE(v_invoice.remaining_amount, v_invoice.total);
+ -- Round-3: effective payment-day rate. SEK_paid / foreign_remaining.
+ IF v_paid_in_inv_currency > 0 THEN
+ v_payment_rate := ROUND((v_alloc_amount / v_paid_in_inv_currency) * 1000000) / 1000000;
+ ELSE
+ v_payment_rate := NULL;
+ END IF;
+ 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 AND company_id = p_company_id;
+
+ 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)
+ VALUES
+ (v_caller, p_company_id, v_invoice_id, v_tx.date, v_paid_in_inv_currency, v_invoice.currency,
+ v_invoice.exchange_rate, v_payment_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 AND company_id = p_company_id;
+
+ IF v_si_invoice.currency = v_tx.currency THEN
+ v_paid_in_inv_currency := v_alloc_amount;
+ v_payment_rate := NULL;
+ ELSE
+ v_paid_in_inv_currency := COALESCE(v_si_invoice.remaining_amount, v_si_invoice.total);
+ IF v_paid_in_inv_currency > 0 THEN
+ v_payment_rate := ROUND((v_alloc_amount / v_paid_in_inv_currency) * 1000000) / 1000000;
+ ELSE
+ v_payment_rate := NULL;
+ END IF;
+ 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 AND company_id = p_company_id;
+
+ INSERT INTO public.supplier_invoice_payments
+ (user_id, company_id, supplier_invoice_id, payment_date, amount, currency, exchange_rate,
+ payment_exchange_rate, journal_entry_id, transaction_id)
+ VALUES
+ (v_caller, p_company_id, v_supplier_invoice_id, v_tx.date, v_paid_in_inv_currency,
+ v_si_invoice.currency, v_si_invoice.exchange_rate, v_payment_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', '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 AND company_id = p_company_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', 0);
+END;
+$$;
+
+NOTIFY pgrst, 'reload schema';
diff --git a/tests/pg/match-batch-allocate.pg.test.ts b/tests/pg/match-batch-allocate.pg.test.ts
index 82f81de8..d0484f74 100644
--- a/tests/pg/match-batch-allocate.pg.test.ts
+++ b/tests/pg/match-batch-allocate.pg.test.ts
@@ -166,8 +166,8 @@ describe('match_batch_allocate', () => {
// it rolls back at the end.
await withUserContext(userId, async (client) => {
const r = await client.query<{ match_batch_allocate: RpcResult }>(
- `SELECT match_batch_allocate($1, $2::jsonb, $3, $4)`,
- [txId, JSON.stringify(allocations), userId, companyId],
+ `SELECT match_batch_allocate($1, $2::jsonb, $3)`,
+ [txId, JSON.stringify(allocations), companyId],
)
const result = r.rows[0]!.match_batch_allocate
@@ -258,8 +258,8 @@ describe('match_batch_allocate', () => {
await withUserContext(userId, async (client) => {
const r = await client.query<{ match_batch_allocate: RpcResult }>(
- `SELECT match_batch_allocate($1, $2::jsonb, $3, $4)`,
- [txId, JSON.stringify(allocations), userId, companyId],
+ `SELECT match_batch_allocate($1, $2::jsonb, $3)`,
+ [txId, JSON.stringify(allocations), companyId],
)
const result = r.rows[0]!.match_batch_allocate
@@ -297,11 +297,10 @@ describe('match_batch_allocate', () => {
await withUserContext(outsiderId, async (client) => {
const r = await client.query<{ match_batch_allocate: RpcResult }>(
- `SELECT match_batch_allocate($1, $2::jsonb, $3, $4)`,
+ `SELECT match_batch_allocate($1, $2::jsonb, $3)`,
[
txId,
JSON.stringify([{ kind: 'supplier_invoice', supplier_invoice_id: si, amount: 1000 }]),
- outsiderId,
companyId,
],
)
@@ -346,8 +345,8 @@ describe('match_batch_allocate', () => {
await withUserContext(userId, async (client) => {
const r = await client.query<{ match_batch_allocate: RpcResult }>(
- `SELECT match_batch_allocate($1, $2::jsonb, $3, $4)`,
- [txId, JSON.stringify(allocations), userId, companyId],
+ `SELECT match_batch_allocate($1, $2::jsonb, $3)`,
+ [txId, JSON.stringify(allocations), companyId],
)
const result = r.rows[0]!.match_batch_allocate
expect(result.ok).toBe(false)
@@ -367,11 +366,10 @@ describe('match_batch_allocate', () => {
await withUserContext(userId, async (client) => {
const r = await client.query<{ match_batch_allocate: RpcResult }>(
- `SELECT match_batch_allocate($1, $2::jsonb, $3, $4)`,
+ `SELECT match_batch_allocate($1, $2::jsonb, $3)`,
[
txId,
JSON.stringify([{ kind: 'supplier_invoice', supplier_invoice_id: si, amount: 1000 }]),
- userId,
companyId,
],
)
@@ -400,8 +398,8 @@ describe('match_batch_allocate', () => {
await withUserContext(userId, async (client) => {
const r = await client.query<{ match_batch_allocate: RpcResult }>(
- `SELECT match_batch_allocate($1, $2::jsonb, $3, $4)`,
- [txId, JSON.stringify(allocations), userId, companyId],
+ `SELECT match_batch_allocate($1, $2::jsonb, $3)`,
+ [txId, JSON.stringify(allocations), companyId],
)
const result = r.rows[0]!.match_batch_allocate
expect(result.ok).toBe(false)
@@ -443,14 +441,13 @@ describe('match_batch_allocate', () => {
await withUserContext(userId, async (client) => {
const r = await client.query<{ match_batch_allocate: RpcResult }>(
- `SELECT match_batch_allocate($1, $2::jsonb, $3, $4)`,
+ `SELECT match_batch_allocate($1, $2::jsonb, $3)`,
[
txId,
JSON.stringify([
{ kind: 'supplier_invoice', supplier_invoice_id: si, amount: 1000 },
{ kind: 'customer_invoice', invoice_id: invoiceId, amount: 1000 },
]),
- userId,
companyId,
],
)
@@ -459,4 +456,158 @@ describe('match_batch_allocate', () => {
expect(result.code).toBe('BATCH_MIXED_KINDS_UNSUPPORTED')
})
})
+
+ // PR #607 — cross-currency happy path. One USD supplier invoice paid by
+ // a single SEK bank transaction. The RPC must book the AP line at the
+ // invoice's original SEK value (booked_sek = remaining × exchange_rate)
+ // and post the difference between booked_sek and the actual bank
+ // withdrawal to 7960 (loss) or 3960 (gain). Bank line is the full tx_abs.
+ it('books cross-currency supplier invoice with FX diff line and tx_abs bank line', async () => {
+ const { userId, companyId } = await seedTenant()
+ const supplier = await insertSupplier({ userId, companyId })
+
+ // USD invoice for $100, booked at 10.0 SEK/USD = 1000 SEK on 2440 at
+ // creation time. (We use the standard insertSupplierInvoice and patch
+ // the currency/exchange_rate after so we don't have to thread params
+ // through the helper.)
+ const si = await insertSupplierInvoice({
+ userId, companyId, supplierId: supplier, total: 100,
+ })
+ await getPool().query(
+ `UPDATE public.supplier_invoices
+ SET currency = 'USD', exchange_rate = 10.0, remaining_amount = 100
+ WHERE id = $1`,
+ [si],
+ )
+
+ // Bank actually withdrew 1050 SEK — rate moved to ~10.5 SEK/USD on
+ // payment day. Loss of 50 SEK lands on 7960.
+ const txId = await insertTransaction({
+ userId, companyId, amount: -1050, date: '2026-06-05', currency: 'SEK',
+ })
+
+ await withUserContext(userId, async (client) => {
+ const r = await client.query<{ match_batch_allocate: RpcResult }>(
+ `SELECT match_batch_allocate($1, $2::jsonb, $3)`,
+ [
+ txId,
+ JSON.stringify([
+ { kind: 'supplier_invoice', supplier_invoice_id: si, amount: 1050 },
+ ]),
+ companyId,
+ ],
+ )
+ const result = r.rows[0]!.match_batch_allocate
+ expect(result.ok).toBe(true)
+ expect(result.allocations).toHaveLength(1)
+ expect(result.allocations![0]!.cross_currency).toBe(true)
+ expect(result.allocations![0]!.status).toBe('paid')
+
+ const lines = await client.query<{
+ account_number: string
+ debit_amount: string
+ credit_amount: string
+ }>(
+ `SELECT account_number, debit_amount, credit_amount
+ FROM public.journal_entry_lines
+ WHERE journal_entry_id = $1
+ ORDER BY sort_order`,
+ [result.journal_entry_id],
+ )
+
+ // Expected lines:
+ // Dr 2440 1000 (booked SEK at original rate)
+ // Dr 7960 50 (FX loss = bank tx — booked SEK)
+ // Cr 1930 1050 (actual bank withdrawal)
+ expect(lines.rows).toHaveLength(3)
+
+ const ap = lines.rows.find((l) => l.account_number === '2440')!
+ expect(Number(ap.debit_amount)).toBe(1000)
+ expect(Number(ap.credit_amount)).toBe(0)
+
+ const fxLoss = lines.rows.find((l) => l.account_number === '7960')!
+ expect(Number(fxLoss.debit_amount)).toBe(50)
+ expect(Number(fxLoss.credit_amount)).toBe(0)
+
+ const bank = lines.rows.find((l) => l.account_number === '1930')!
+ expect(Number(bank.debit_amount)).toBe(0)
+ expect(Number(bank.credit_amount)).toBe(1050)
+
+ // Round-1 fix: bank line credit must equal tx_abs, not the AR/AP
+ // total. With FX diff lines this distinction matters — verify it.
+ expect(Number(bank.credit_amount)).toBe(1050)
+
+ // Supplier invoice settled in full and stored in invoice currency.
+ const inv = await client.query<{
+ status: string; paid_amount: string; remaining_amount: string
+ }>(
+ `SELECT status, paid_amount, remaining_amount FROM public.supplier_invoices WHERE id = $1`,
+ [si],
+ )
+ expect(inv.rows[0]!.status).toBe('paid')
+ expect(Number(inv.rows[0]!.paid_amount)).toBe(100) // USD value, not SEK
+ expect(Number(inv.rows[0]!.remaining_amount)).toBe(0)
+
+ // Round-3: payment row stores the effective payment-day rate
+ // (v_alloc_amount / v_inv_remaining = 1050/100 = 10.5) alongside
+ // the invoicing rate (10.0). swedish-compliance traceability fix.
+ const pay = await client.query<{
+ exchange_rate: string | null; payment_exchange_rate: string | null
+ }>(
+ `SELECT exchange_rate, payment_exchange_rate
+ FROM public.supplier_invoice_payments
+ WHERE supplier_invoice_id = $1`,
+ [si],
+ )
+ expect(Number(pay.rows[0]!.exchange_rate)).toBe(10) // invoicing rate
+ expect(Number(pay.rows[0]!.payment_exchange_rate)).toBe(10.5) // payment-day rate
+
+ // Sum of debits = sum of credits (balanced verifikat).
+ const balance = await client.query<{ debits: string; credits: string }>(
+ `SELECT
+ COALESCE(SUM(debit_amount), 0) AS debits,
+ COALESCE(SUM(credit_amount), 0) AS credits
+ FROM public.journal_entry_lines
+ WHERE journal_entry_id = $1`,
+ [result.journal_entry_id],
+ )
+ expect(Number(balance.rows[0]!.debits)).toBe(Number(balance.rows[0]!.credits))
+ })
+ })
+
+ // PR #607 round-1 — strict undershoot rejection. The RPC previously
+ // accepted sum(allocations) < tx_abs and silently underbooked the bank
+ // line, breaking reconciliation. Now it must reject with
+ // BATCH_AMOUNT_BELOW_TX.
+ it('rejects BATCH_AMOUNT_BELOW_TX when allocations sum below tx_abs', async () => {
+ const { userId, companyId } = await seedTenant()
+ const supplier = await insertSupplier({ userId, companyId })
+ const si = await insertSupplierInvoice({
+ userId, companyId, supplierId: supplier, total: 1000,
+ })
+ const txId = await insertTransaction({ userId, companyId, amount: -1500 })
+
+ await withUserContext(userId, async (client) => {
+ const r = await client.query<{ match_batch_allocate: RpcResult }>(
+ `SELECT match_batch_allocate($1, $2::jsonb, $3)`,
+ [
+ txId,
+ JSON.stringify([
+ { kind: 'supplier_invoice', supplier_invoice_id: si, amount: 1000 },
+ ]),
+ companyId,
+ ],
+ )
+ const result = r.rows[0]!.match_batch_allocate
+ expect(result.ok).toBe(false)
+ expect(result.code).toBe('BATCH_AMOUNT_BELOW_TX')
+ expect(result.details).toMatchObject({ allocated: 1000, tx_amount_abs: 1500 })
+
+ const txRow = await client.query<{ journal_entry_id: string | null }>(
+ `SELECT journal_entry_id FROM public.transactions WHERE id = $1`,
+ [txId],
+ )
+ expect(txRow.rows[0]!.journal_entry_id).toBeNull()
+ })
+ })
})