fix(match-batch): cross-currency allocations + widened tolerance (#607)
* fix(match-batch): cross-currency allocations + widened tolerance Reported by jakob testing PR #603's MatchAllocationDialog with a SEK bank tx + a mix of SEK and USD invoices: 1. Tally rendered "1 USD + 1 SEK = 2 kr" — summing different currencies as if they were the same. 2. The 0.005 SEK tolerance blocked confirm on any FX rounding delta. ## What changed **UI (MatchAllocationDialog.tsx)** - Per-row amount input is explicitly in TRANSACTION currency (SEK for a Swedish bank import). Cross-currency rows show an "≈ X.XX (invoice currency)" hint under the input so the user can verify the FX result. - Default amount for a cross-currency allocation is `invoice.remaining × invoice.exchange_rate` (booked SEK), so the user doesn't have to mental-math the FX. - Overshoot tolerance widened from 0.005 SEK to `max(1 SEK, 0.5% × tx)` so bank-side FX rounding doesn't block confirm. A 2 400 kr tx now accepts ~12 kr of tolerance, a 100 kkr transfer accepts 500 kr. **RPC (match_batch_allocate cross_currency migration)** - BATCH_CURRENCY_MISMATCH dropped per-allocation. Mixed currencies now accepted with the convention that the cross-currency row pays the FULL invoice remaining (matches the single-tx match-supplier-invoice behavior). Partial cross-currency is out of scope for v1. - AR/AP line is booked at `invoice.remaining × invoice.exchange_rate` (the SEK that was originally on 1510/2440). FX residual is posted to 7960 (Valutakursförluster) or 3960 (Valutakursvinster) per BAS. - Sign conventions per direction documented inline: Customer: bank > booked → Cr 3960 (gain); bank < booked → Dr 7960 Supplier: bank < booked → Cr 3960 (gain); bank > booked → Dr 7960 - New BATCH_FX_RATE_MISSING when the cross-currency invoice has no exchange_rate on file (would otherwise silently book at 0). - New BATCH_FX_DEVIATION_TOO_LARGE when the user-entered amount deviates more than 10% from booked SEK — catches typos like "140" (USD invoice currency) when they meant "1390" (SEK equivalent) without rejecting genuine rate-day FX movement. RPC patched on remote via Supabase MCP. Same-currency path is byte-identical to the previous behavior. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(match-batch): PR review — strict sum, bank line = tx_abs, FX validation Round-1 review fixes on the cross-currency batch allocation flow: UI (MatchAllocationDialog): - Tighten tolerance to 0.005 SEK so the "balanced ✓" indicator matches what the server will accept. The previous widened tolerance (max 1 SEK or 0.5% × tx) created a reconciliation gap where the JE's bank line could legitimately disagree with the actual bank receipt. - Require balanced before confirm — undershoot is now a blocking state with an explicit warning, not a silent "leave unallocated". - Cross-currency default no longer caps at remainingTxBudget. Capping a USD invoice's default to the leftover SEK budget could silently trigger BATCH_FX_DEVIATION_TOO_LARGE on submit. The user re-balances the other rows to fit. - Add explicit FX-rate validation (bound check 0 < rate < 100000). - When a cross-currency invoice has no usable exchange_rate on file, leave the amount blank and surface a warning instead of guessing. RPC (match_batch_allocate): - New code BATCH_AMOUNT_BELOW_TX. Strict sum check on both sides means the server can't be coaxed by a direct API caller into the same broken state the UI now blocks. - Bank line credit/debit = v_tx_abs (the actual bank movement) instead of sum-of-allocations. Same value within rounding under the strict sum check, but it makes intent legible and lets per-row FX diff lines absorb rounding. - Defense-in-depth company_id filter on all re-queries / UPDATEs in the line-build + payment-row passes. - Drop the v_booked_sek-aliasing-for-invoice.total foot-gun. Use a dedicated v_inv_total var. - Truncate invoice_number to 32 chars in line_description. Tests: - pg-real: cross-currency happy path (USD invoice paid by SEK tx with FX loss to 7960, bank line = tx_abs). - pg-real: BATCH_AMOUNT_BELOW_TX rejection on undershoot. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(match-batch): PR review round 2 - caller user_id verification + FX bound Compliance-swarm + swedish-compliance findings on round 1: - CC6.3 (HIGH): p_user_id was caller-supplied and written into journal_entries.user_id / payment-row user_id without verifying it equals auth.uid(). Membership covered the company; nothing covered the user attribution. Two-layer fix: explicit guard rejects when p_user_id <> auth.uid(), and all writes now resolve v_caller = auth.uid() directly so the guard cant be silently bypassed. - A.8.28 (MED): server-side FX upper-bound (0 < rate < 100000) matches the UI. Previously RPC only checked > 0, allowing the UI guard to diverge. - V1.2.5 (LOW): truncate v_tx.date when concatenated into line_description (defense alongside round 1s invoice_number trunc). - Symmetry: populate supplier_invoice_payments.exchange_rate (column existed, INSERT omitted it). Customer side already populated. Matches swedish-compliances traceability note on AP rorelseskulder. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(match-batch): PR review round 3 - drop p_user_id, CHECK constraints, payment-day rate Genuine round-2 review findings (compliance-swarm + swedish-compliance): - V4.5: p_user_id dropped from RPC signature entirely. Round-2 added a guard; this removes the attack surface at the API boundary. Caller is resolved via auth.uid() inside the function. Route updated. - V2.2: CHECK constraint on invoices.exchange_rate and supplier_invoices.exchange_rate (0 < rate < 100000). Three layers now enforce the bound: schema, RPC, UI. - swedish-compliance traceability gap: payment_exchange_rate column on both invoice_payments and supplier_invoice_payments. Populated as v_alloc_amount / v_inv_remaining for cross-currency rows so FX diffs are reconstructible from the payment record alone (BFL 7 kap behandlingshistorik). NULL for same-currency. The existing exchange_rate column continues to store the invoicing rate. - CC6.1: extract isValidExchangeRate() to lib/utils.ts. UI's three inline bound checks now share one validator. - Dead code: drop unused leftover_note i18n key (sv + en). Tests: - pg-real signature updated (4-arg -> 3-arg) across all 9 call sites. - Added payment_exchange_rate assertion to cross-currency happy path (invoicing rate 10.0 stays, payment-day rate stored as 10.5). Migration applied to remote. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(test): missed 4th arg in BATCH_UNAUTHORIZED pg-real test Round-3 dropped p_user_id from match_batch_allocate. The replace_all caught the userId/companyId pattern but missed the BATCH_UNAUTHORIZED test which uses outsiderId instead of userId. CI failed with "bind message supplies 4 parameters, but prepared statement requires 3". Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
@@ -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({
|
||||
</p>
|
||||
</div>
|
||||
{isSelected ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={draft.amount}
|
||||
onChange={(e) => 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 })}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() => removeAllocation(c.id)}
|
||||
aria-label={t('remove_aria', { label: c.label })}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={draft.amount}
|
||||
onChange={(e) => 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 })}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() => removeAllocation(c.id)}
|
||||
aria-label={t('remove_aria', { label: c.label })}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{/* 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) ? (
|
||||
<p className="text-[11px] tabular-nums text-muted-foreground">
|
||||
≈ {formatCurrency(parseAmount(draft.amount) / c.exchangeRate, c.currency)}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-[11px] tabular-nums text-warning-foreground">
|
||||
{t('fx_rate_missing_warning', { currency: c.currency })}
|
||||
</p>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
@@ -450,12 +510,19 @@ export default function MatchAllocationDialog({
|
||||
<Check className="h-4 w-4 flex-shrink-0" />
|
||||
<p>{t('balanced_message')}</p>
|
||||
</div>
|
||||
) : leftover > 0.005 && Object.keys(drafts).length > 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('leftover_note', {
|
||||
amount: formatCurrency(leftover, transaction.currency),
|
||||
})}
|
||||
</p>
|
||||
) : 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.
|
||||
<div className="flex items-start gap-2 rounded-lg bg-warning/10 p-3 text-sm text-warning-foreground">
|
||||
<AlertTriangle className="h-4 w-4 flex-shrink-0 mt-0.5" />
|
||||
<p>
|
||||
{t('undershoot_warning', {
|
||||
amount: formatCurrency(leftover, transaction.currency),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
@@ -466,7 +533,10 @@ export default function MatchAllocationDialog({
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
disabled={submitting || overshoot || Object.keys(drafts).length === 0}
|
||||
// Confirm requires sum == tx_abs exactly (within rounding).
|
||||
// Anything else lets the JE diverge from the bank line and
|
||||
// breaks reconciliation. PR #607 round-1 review fix.
|
||||
disabled={submitting || !balanced || overshoot}
|
||||
>
|
||||
{submitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{t('confirm')}
|
||||
|
||||
@@ -1887,6 +1887,13 @@ const MATCH_BATCH: Record<string, StructuredErrorEntry> = {
|
||||
'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<string, StructuredErrorEntry> = {
|
||||
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:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+2
-1
@@ -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",
|
||||
|
||||
+2
-1
@@ -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",
|
||||
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user