* 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>
164 lines
5.5 KiB
TypeScript
164 lines
5.5 KiB
TypeScript
import { NextResponse } from 'next/server'
|
|
import { withRouteContext } from '@/lib/api/with-route-context'
|
|
import { validateBody } from '@/lib/api/validate'
|
|
import { MatchBatchSchema } from '@/lib/api/schemas'
|
|
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
|
import { eventBus } from '@/lib/events/bus'
|
|
import { ensureInitialized } from '@/lib/init'
|
|
import type { Invoice, SupplierInvoice, Transaction } from '@/types'
|
|
|
|
ensureInitialized()
|
|
|
|
interface RpcAllocationResult {
|
|
kind: 'customer_invoice' | 'supplier_invoice'
|
|
invoice_id?: string
|
|
supplier_invoice_id?: string
|
|
payment_id: string
|
|
status: 'paid' | 'partially_paid'
|
|
paid_amount: number
|
|
remaining_amount: number
|
|
amount: number
|
|
}
|
|
|
|
interface RpcOk {
|
|
ok: true
|
|
journal_entry_id: string
|
|
voucher_series: string
|
|
voucher_number: number
|
|
tx_id: string
|
|
allocations: RpcAllocationResult[]
|
|
total_allocated: number
|
|
leftover: number
|
|
}
|
|
|
|
interface RpcErr {
|
|
ok: false
|
|
code: string
|
|
details?: Record<string, unknown>
|
|
}
|
|
|
|
/**
|
|
* POST /api/transactions/[id]/match-batch
|
|
*
|
|
* Allocate one bank transaction across N customer OR N supplier invoices.
|
|
* Builds a single combined verifikat (samlingsverifikation) and inserts N
|
|
* payment rows via the match_batch_allocate PL/pgSQL RPC.
|
|
*
|
|
* The RPC is the atomicity boundary; this route is a thin wrapper that:
|
|
* 1. Validates the request body via MatchBatchSchema.
|
|
* 2. Invokes the RPC.
|
|
* 3. Maps the structured RPC error (jsonb { ok: false, code }) to an
|
|
* errorResponseFromCode call.
|
|
* 4. On success, refetches the per-allocation invoice/supplier_invoice rows
|
|
* to emit the same per-allocation events the legacy single-tx routes
|
|
* emit (invoice.match_confirmed, invoice.paid, supplier_invoice.*).
|
|
* Event emission is best-effort — a failure here does not roll back
|
|
* the booking; the RPC commit is the source of truth.
|
|
*/
|
|
export const POST = withRouteContext(
|
|
'transaction.match_batch',
|
|
async (request, ctx, { params }: { params: Promise<{ id: string }> }) => {
|
|
const { id: transactionId } = await params
|
|
const { user, supabase, companyId, log, requestId } = ctx
|
|
|
|
const validation = await validateBody(request, MatchBatchSchema, {
|
|
log,
|
|
operation: 'transaction.match_batch',
|
|
})
|
|
if (!validation.success) return validation.response
|
|
|
|
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_company_id: companyId,
|
|
})
|
|
|
|
if (error) {
|
|
txLog.error('match_batch_allocate RPC error', error)
|
|
return errorResponseFromCode('BATCH_RPC_FAILED', txLog, {
|
|
requestId,
|
|
details: { message: error.message },
|
|
})
|
|
}
|
|
|
|
const result = data as RpcOk | RpcErr | null
|
|
if (!result || !result.ok) {
|
|
const code = (result as RpcErr | null)?.code ?? 'BATCH_RPC_FAILED'
|
|
const details = (result as RpcErr | null)?.details
|
|
return errorResponseFromCode(code, txLog, { requestId, details })
|
|
}
|
|
|
|
// Re-fetch the transaction row for event payloads (the RPC has already
|
|
// updated it). Lookup is non-critical — events fail open on miss.
|
|
const { data: tx } = await supabase
|
|
.from('transactions')
|
|
.select('*')
|
|
.eq('id', transactionId)
|
|
.eq('company_id', companyId)
|
|
.maybeSingle()
|
|
|
|
// Emit one event per allocation so existing subscribers (reminder
|
|
// cancellation, automation, processing-history) keep working without a
|
|
// new event channel. Loop sequentially so a single failure logs cleanly.
|
|
for (const alloc of result.allocations) {
|
|
try {
|
|
if (alloc.kind === 'customer_invoice' && alloc.invoice_id) {
|
|
const { data: invoice } = await supabase
|
|
.from('invoices')
|
|
.select('*')
|
|
.eq('id', alloc.invoice_id)
|
|
.eq('company_id', companyId)
|
|
.maybeSingle()
|
|
if (invoice && tx) {
|
|
await eventBus.emit({
|
|
type: 'invoice.match_confirmed',
|
|
payload: {
|
|
invoice: invoice as Invoice,
|
|
transaction: tx as Transaction,
|
|
userId: user.id,
|
|
companyId,
|
|
},
|
|
})
|
|
}
|
|
} else if (alloc.kind === 'supplier_invoice' && alloc.supplier_invoice_id) {
|
|
const { data: supplierInvoice } = await supabase
|
|
.from('supplier_invoices')
|
|
.select('*')
|
|
.eq('id', alloc.supplier_invoice_id)
|
|
.eq('company_id', companyId)
|
|
.maybeSingle()
|
|
if (supplierInvoice && tx) {
|
|
await eventBus.emit({
|
|
type: 'supplier_invoice.match_confirmed',
|
|
payload: {
|
|
supplierInvoice: supplierInvoice as SupplierInvoice,
|
|
transaction: tx as Transaction,
|
|
userId: user.id,
|
|
companyId,
|
|
},
|
|
})
|
|
}
|
|
}
|
|
} catch (err) {
|
|
txLog.warn('match_batch event emission failed', err as Error)
|
|
}
|
|
}
|
|
|
|
return NextResponse.json({
|
|
data: {
|
|
journal_entry_id: result.journal_entry_id,
|
|
voucher_series: result.voucher_series,
|
|
voucher_number: result.voucher_number,
|
|
allocations: result.allocations,
|
|
total_allocated: result.total_allocated,
|
|
leftover: result.leftover,
|
|
},
|
|
})
|
|
},
|
|
{ requireWrite: true },
|
|
)
|