Files
accounted/lib/utils.ts
T
Jakob Wennberg fc7a46c3f2 fix(match-batch): cross-currency allocations + widened tolerance (#607)
* fix(match-batch): cross-currency allocations + widened tolerance

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

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

## What changed

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Migration applied to remote.

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

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

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

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

---------

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

99 lines
3.5 KiB
TypeScript

import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
import { format as formatDateFns, parseISO } from "date-fns"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function formatCurrency(amount: number, currency: string = 'SEK'): string {
return new Intl.NumberFormat('sv-SE', {
style: 'currency',
currency,
minimumFractionDigits: 0,
maximumFractionDigits: 2,
}).format(amount)
}
export function formatDate(date: Date | string): string {
// parseISO interprets bare 'yyyy-MM-dd' as local midnight, not UTC midnight.
// Using new Date() would shift the displayed day by one in timezones west of
// UTC for bare date strings — that's an off-by-one we don't want for
// accounting data.
const d = typeof date === 'string' ? parseISO(date) : date
return formatDateFns(d, 'yyyy-MM-dd')
}
/**
* Long-form date for metadata/audit contexts (e.g. "9 maj 2026" / "May 9, 2026").
* Use formatDate for transaction/voucher/invoice dates that need to align in tables.
*
* The locale arg is the UI language ('sv' | 'en'); default 'sv' keeps existing
* server-side callers (logs, audit) Swedish without churn. For client UI use
* the useFormat() hook which pulls the active locale from next-intl.
*/
export function formatDateLong(date: Date | string, locale: string = 'sv'): string {
const d = typeof date === 'string' ? parseISO(date) : date
const intlLocale = locale === 'en' ? 'en-US' : 'sv-SE'
return d.toLocaleDateString(intlLocale, {
day: 'numeric',
month: 'short',
year: 'numeric',
})
}
/**
* Today's date in Europe/Stockholm, labelled for the bookkeeping agent's system
* prompt — e.g. "2026-05-27 (onsdag)".
*
* Date granularity (no clock time) is deliberate: the agent system prompt is
* cached (cache_control ttl=1h) and this string sits inside the cached prefix,
* so a full timestamp would bust the cache on every request while the value
* actually changes at most once a day. Stockholm time zone — not the server's
* UTC — so "idag" is right for Swedish users near midnight, where a UTC date can
* read a day behind.
*/
export function swedishToday(now: Date = new Date()): string {
const date = new Intl.DateTimeFormat('sv-SE', {
timeZone: 'Europe/Stockholm',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(now)
const weekday = new Intl.DateTimeFormat('sv-SE', {
timeZone: 'Europe/Stockholm',
weekday: 'long',
}).format(now)
return `${date} (${weekday})`
}
export function formatOrgNumber(orgNumber: string): string {
// Format Swedish org number: XXXXXX-XXXX
const cleaned = orgNumber.replace(/\D/g, '')
if (cleaned.length === 10) {
return `${cleaned.slice(0, 6)}-${cleaned.slice(6)}`
}
return orgNumber
}
export function getCompanyDisplayName(settings: { company_name?: string | null }): string {
return settings.company_name?.trim() || ''
}
export function getCompanyPrimaryName(settings: { company_name?: string | null }): string {
return settings.company_name?.trim() || ''
}
export function generateInvoiceNumber(): string {
const year = new Date().getFullYear()
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
}