9ed0b9515a
* feat(invoices): add Plusgiro input to bank details settings Plusgiro was already persisted, validated by the API schema, rendered on the invoice PDF and toggleable via "Visa plusgiro" — but the settings UI had no field to enter the number, so plusgiro-only users could not fill it in. Add the input next to Bankgiro with Luhn validation and hyphen formatting, include it in the save payload (normalised on save so raw digits still match the dashed schema format), and add sv/en strings. Adds validatePlusgiroNumber/formatPlusgiroNumber helpers + tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(invoices): respect non-VAT-registered seller in PDF preview + portal tooltips Two user-reported bugs: - PDF preview (/api/invoices/preview-pdf) ignored company.vat_registered and fell back to the customer-driven 25% rate, so a non-momsregistrerad seller saw VAT in the review step even though the created invoice books none. Mirror the server-side write gate (build-invoice-write.ts): force 0% when vat_registered is false (delivery notes excepted). - InfoTooltip rendered TooltipContent without a Portal, so tooltips were clipped by the scrollable DialogContent (overflow-y-auto) in the send-invoice journal-entry review. Wrap in TooltipPrimitive.Portal. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(transactions): book library mall from its literal lines, not a lossy fallback Booking a bank transaction with a user-created booking-template (mall) via the convertible "QuickReview" fast path reduced the template to a single category + one account_override, silently discarding the chosen debit/credit. A kundinbetalning mall (D 1930 / K 1510) booked as a generic cost (D 6991 / K 1930), or with a VAT line as D 1930 / K 1930 / K 2611 — and the result flipped with the direction inferred from the business/settlement line tags, so visually-identical templates produced different verifikationer. Route every library template through the journal-entry editor (applyTemplate -> /book), which posts the literal lines, regardless of convertibility. Add regression tests locking the contract. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(bookkeeping): make the booking-time duplicate guard bypassable TRANSACTION_BOOK_POSSIBLE_DUPLICATE told users they could "book anyway" but the UI dead-ended on a toast with no way to do so. Add a shared DuplicateBookingDialog that surfaces the already-booked sibling and lets the user review it or book anyway (force bound to the reviewed candidate, which the server re-detects so a stale id cannot wave the guard away). - Wire the dialog into the /transactions categorize flow and the manual booking dialog (JournalEntryForm -> /api/transactions/[id]/book) - Bind the override to expected_duplicate_transaction_id OR expected_duplicate_journal_entry_id so ledger-only vouchers (paid invoice, salary run) can be confirmed too - Extend the guard to the pending-operations commit path and the MCP server - Tests for book/categorize routes, detection, and the commit guard Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(bookkeeping): log duplicate-guard bypass to behandlingshistorik in the agent commit path The web /book and /categorize routes append a durable BankTransactionDuplicateDismissed event when a user books over a detected possible double-booking. The agent commit path (commitCategorizeTransaction, commitMarkInvoicePaid) skipped the guard silently on allow_duplicate=true, leaving no behandlingshistorik — an auditor could not reconstruct why the duplicate was allowed (BFNAR 2013:2 kap 8). When allow_duplicate=true, re-detect the candidate and append the dismissal event (BankTransactionDuplicateDismissed for the bank-line path, InvoiceDuplicatePaymentDismissed for mark-paid). Best-effort — a logging failure never blocks a legitimate booking. Payloads stay PII-safe (ids, amounts, dates only — no customer or merchant name). Also fix the misleading DuplicateBookingDialog JSDoc: the retry binds expected_duplicate_journal_entry_id, not candidate.transaction_id, so the systemdokumentation matches the actual control (BFL 7 kap). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(mcp-server): stub booking-duplicate guard in receipt-matcher categorize tests The gnubok_categorize_transaction tool runs the booking-time duplicate guard before staging; its detection queries consumed the queued supabase mock results, so the staging assertions saw a thrown duplicate error instead of a staged op. Mock detectBookingDuplicate to "no duplicate" since these tests don't exercise that path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(transactions): use roundOre for duplicate-guard öre rounding Replace naive Math.round(x*100)/100 with roundOre() from @/lib/money in the booking-time duplicate guard (detection lib, commit executor, MCP categorize tool), satisfying the no-new-antipatterns ratchet guard. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
317 lines
13 KiB
TypeScript
317 lines
13 KiB
TypeScript
/**
|
|
* Booking-time duplicate guard for bank transactions.
|
|
*
|
|
* Why this exists
|
|
* ---------------
|
|
* A bank account's transactions can land in the `transactions` table twice — a
|
|
* CSV import on top of a PSD2 sync, or a re-sync whose external_id drifted (see
|
|
* the import dedup in lib/transactions/ingest.ts). Import-time dedup is
|
|
* best-effort and can miss. The cosmetic cost of a missed duplicate is a second
|
|
* row in the "Att bokföra" list. The REAL cost is booking BOTH copies: that
|
|
* creates two verifikationer for one affärshändelse, double-counts the
|
|
* cost/income, and is felaktig bokföring under BFL (the second verifikat has no
|
|
* underlying event). Rättelse would then require storno, not deletion.
|
|
*
|
|
* This guard runs at booking time. Before a transaction becomes a verifikat it
|
|
* looks for ANOTHER transaction in the same company that is already booked and
|
|
* shares this one's (date, amount, cash account). If found, the caller surfaces
|
|
* it as a WARNING — never a hard block, because genuinely repeated
|
|
* same-(date,amount) payments do occur (e.g. several identical Swish transfers
|
|
* in one day). The user confirms with force=true after reviewing the candidate.
|
|
*
|
|
* Mirrors the invoice-side `detectDuplicatePaymentVoucher`
|
|
* (lib/invoices/duplicate-payment-detection.ts), but keyed on an already-booked
|
|
* sibling TRANSACTION rather than a manually-posted journal entry.
|
|
*/
|
|
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
import { roundOre } from '@/lib/money'
|
|
|
|
/** Integer öre — representation-agnostic amount key (mirrors the ingest dedup). */
|
|
function toOre(amount: number | string): number {
|
|
return Math.round(Number(amount) * 100)
|
|
}
|
|
|
|
/** An already-booked transaction OR voucher that looks like the same real movement. */
|
|
export interface BookedDuplicateCandidate {
|
|
/**
|
|
* The sibling transaction that is already booked, or `null` when the duplicate
|
|
* is a ledger-only voucher (a payment/payout booked straight to the cash
|
|
* account with no transaction row behind it — see detectLedgerDuplicateVoucher).
|
|
*/
|
|
transaction_id: string | null
|
|
/** Its verifikat. */
|
|
journal_entry_id: string
|
|
/** Human label, e.g. "A142" (voucher_series + voucher_number). */
|
|
voucher_label: string
|
|
entry_date: string
|
|
description: string | null
|
|
amount: number
|
|
}
|
|
|
|
/** Minimal shape of the transaction about to be booked. */
|
|
export interface BookingTarget {
|
|
id: string
|
|
date: string
|
|
amount: number | string
|
|
cash_account_id?: string | null
|
|
}
|
|
|
|
/**
|
|
* Find an already-booked sibling transaction sharing (date, amount, account).
|
|
* Returns the single best candidate, or null.
|
|
*
|
|
* Account guard mirrors the import dedup bridge: when BOTH sides know their
|
|
* cash_account_id they must match; a null on either side is treated as
|
|
* compatible (single-account companies and un-backfilled rows behave as before).
|
|
*
|
|
* Fail-open: a query error returns null rather than throwing — a detection
|
|
* failure must never block a legitimate booking. The pick is deterministic
|
|
* (lowest id) so a re-detection under force=true returns the same candidate the
|
|
* user reviewed.
|
|
*/
|
|
export async function detectBookedDuplicateTransaction(
|
|
supabase: SupabaseClient,
|
|
companyId: string,
|
|
target: BookingTarget,
|
|
): Promise<BookedDuplicateCandidate | null> {
|
|
const targetOre = toOre(target.amount)
|
|
if (targetOre === 0 || Number.isNaN(targetOre)) return null
|
|
|
|
// Same company, same date, already booked, not the target row itself. The
|
|
// amount and account match is applied in JS so a numeric-string amount from
|
|
// PostgREST ("-1616.00") collapses to the same öre as the number (-1616).
|
|
const { data, error } = await supabase
|
|
.from('transactions')
|
|
.select('id, date, amount, description, cash_account_id, journal_entry_id')
|
|
.eq('company_id', companyId)
|
|
.eq('date', target.date)
|
|
.not('journal_entry_id', 'is', null)
|
|
.neq('id', target.id)
|
|
.limit(100)
|
|
|
|
if (error || !data || data.length === 0) return null
|
|
|
|
type Row = {
|
|
id: string
|
|
date: string
|
|
amount: number | string
|
|
description: string | null
|
|
cash_account_id: string | null
|
|
journal_entry_id: string
|
|
}
|
|
const targetAccount = target.cash_account_id ?? null
|
|
const matches = (data as unknown as Row[]).filter((r) => {
|
|
if (toOre(r.amount) !== targetOre) return false
|
|
// Account guard: both-known must match; a null on either side is compatible.
|
|
if (targetAccount !== null && r.cash_account_id !== null && r.cash_account_id !== targetAccount) {
|
|
return false
|
|
}
|
|
return r.journal_entry_id != null
|
|
})
|
|
if (matches.length === 0) return null
|
|
|
|
matches.sort((a, b) => a.id.localeCompare(b.id))
|
|
const best = matches[0]
|
|
|
|
// Resolve the voucher label for the warning (best-effort — a missing label
|
|
// still yields a usable candidate the UI can render by date/amount).
|
|
let voucherLabel = ''
|
|
let entryDate = best.date
|
|
const { data: je } = await supabase
|
|
.from('journal_entries')
|
|
.select('voucher_series, voucher_number, entry_date')
|
|
.eq('id', best.journal_entry_id)
|
|
.maybeSingle()
|
|
if (je) {
|
|
const j = je as { voucher_series: string | null; voucher_number: number | null; entry_date: string | null }
|
|
voucherLabel = `${j.voucher_series ?? 'A'}${j.voucher_number ?? ''}`
|
|
entryDate = j.entry_date ?? best.date
|
|
}
|
|
|
|
return {
|
|
transaction_id: best.id,
|
|
journal_entry_id: best.journal_entry_id,
|
|
voucher_label: voucherLabel,
|
|
entry_date: entryDate,
|
|
description: best.description,
|
|
amount: roundOre(Number(best.amount)),
|
|
}
|
|
}
|
|
|
|
/** ± days around the bank-tx date a voucher may be dated and still be "the same" movement. */
|
|
const VOUCHER_DUPLICATE_DATE_WINDOW_DAYS = 7
|
|
|
|
/** BAS "kassa och bank" range. 1910-1919 = kassa, 1920-1949 = bank/giro. */
|
|
const BANK_ACCOUNT_LOW = 1910
|
|
const BANK_ACCOUNT_HIGH = 1949
|
|
|
|
/**
|
|
* Find an unlinked posted voucher whose bank/cash (19xx) leg already books this
|
|
* exact bank movement — the ledger-only twin of the bank line.
|
|
*
|
|
* This is the second half of the booking-time duplicate guard. The first half
|
|
* (detectBookedDuplicateTransaction) only finds an already-booked SIBLING
|
|
* TRANSACTION. But the most damaging orphan has NO sibling transaction at all:
|
|
* the affärshändelse was booked through a flow that posts straight to the ledger
|
|
* and never creates or links a bank-transaction row — invoice "markera som
|
|
* betald" (Dr 19xx / Cr 1510), the salary run's net-wage payout (Cr 19xx), a
|
|
* hand-posted verifikat. Booking the bank line on top of that double-counts the
|
|
* movement on the cash account: two verifikationer for one affärshändelse,
|
|
* felaktig bokföring per BFL. Because the import dedup and the sibling guard
|
|
* both only see the `transactions` table, neither catches this — only matching
|
|
* the bank line against the ledger does.
|
|
*
|
|
* Direction-aware so it works both ways:
|
|
* - inbound (target.amount > 0, money in) → a 19xx DEBIT of the same amount
|
|
* - outbound (target.amount < 0, money out) → a 19xx CREDIT of the same amount
|
|
*
|
|
* Account-aware: when the bank line knows its cash account, the matching leg
|
|
* must be on that account's ledger account; otherwise any 19xx leg matches
|
|
* (single-account companies, legacy rows with no cash_account_id).
|
|
*
|
|
* Excludes vouchers already linked to a transaction or an invoice_payment (those
|
|
* are reconciled, not orphans) and storno/correction entries (valid second
|
|
* vouchers, not duplicates). Fail-open: a query error returns null so a
|
|
* detection failure never blocks a legitimate booking. The pick is deterministic
|
|
* (closest date, then lowest journal_entry id) so a force re-detect is stable.
|
|
*/
|
|
export async function detectLedgerDuplicateVoucher(
|
|
supabase: SupabaseClient,
|
|
companyId: string,
|
|
target: BookingTarget,
|
|
): Promise<BookedDuplicateCandidate | null> {
|
|
const targetOre = toOre(target.amount)
|
|
if (targetOre === 0 || Number.isNaN(targetOre)) return null
|
|
const targetAmount = roundOre(Math.abs(Number(target.amount)))
|
|
const inbound = targetOre > 0
|
|
|
|
const dateMs = new Date(target.date).getTime()
|
|
if (Number.isNaN(dateMs)) return null
|
|
const windowMs = VOUCHER_DUPLICATE_DATE_WINDOW_DAYS * 24 * 3600 * 1000
|
|
const lowDate = new Date(dateMs - windowMs).toISOString().split('T')[0]
|
|
const highDate = new Date(dateMs + windowMs).toISOString().split('T')[0]
|
|
|
|
// Resolve the bank line's settlement ledger account, when known, so a movement
|
|
// on one bank account never deduplicates a voucher on a different account of
|
|
// the same company (the 19xx leg below is matched against it).
|
|
let settlementAccount: string | null = null
|
|
if (target.cash_account_id) {
|
|
const { data: ca } = await supabase
|
|
.from('cash_accounts')
|
|
.select('ledger_account')
|
|
.eq('company_id', companyId)
|
|
.eq('id', target.cash_account_id)
|
|
.maybeSingle()
|
|
settlementAccount = ((ca as { ledger_account?: string } | null)?.ledger_account) ?? null
|
|
}
|
|
|
|
const amountColumn = inbound ? 'debit_amount' : 'credit_amount'
|
|
let query = supabase
|
|
.from('journal_entry_lines')
|
|
.select(
|
|
`account_number,
|
|
debit_amount,
|
|
credit_amount,
|
|
journal_entry:journal_entries!inner(
|
|
id,
|
|
entry_date,
|
|
description,
|
|
voucher_series,
|
|
voucher_number,
|
|
status,
|
|
source_type,
|
|
company_id
|
|
)`,
|
|
)
|
|
.eq('journal_entry.company_id', companyId)
|
|
.eq('journal_entry.status', 'posted')
|
|
.gte('journal_entry.entry_date', lowDate)
|
|
.lte('journal_entry.entry_date', highDate)
|
|
.gt(amountColumn, 0)
|
|
|
|
query = settlementAccount
|
|
? query.eq('account_number', settlementAccount)
|
|
: query.gte('account_number', String(BANK_ACCOUNT_LOW)).lte('account_number', String(BANK_ACCOUNT_HIGH))
|
|
|
|
const { data: lines, error } = await query.limit(50)
|
|
if (error || !lines || lines.length === 0) return null
|
|
|
|
type LineRow = {
|
|
account_number: string
|
|
debit_amount: number | string
|
|
credit_amount: number | string
|
|
journal_entry: {
|
|
id: string
|
|
entry_date: string
|
|
description: string | null
|
|
voucher_series: string | null
|
|
voucher_number: number | null
|
|
status: string
|
|
source_type: string | null
|
|
}
|
|
}
|
|
const candidates = (lines as unknown as LineRow[])
|
|
.filter((l) => {
|
|
const legAmount = roundOre(Number(inbound ? l.debit_amount : l.credit_amount))
|
|
return Math.abs(legAmount - targetAmount) < 0.01
|
|
})
|
|
// Reversals/corrections are valid second vouchers, not duplicate bookings.
|
|
.filter((l) => l.journal_entry.source_type !== 'storno' && l.journal_entry.source_type !== 'correction')
|
|
|
|
if (candidates.length === 0) return null
|
|
|
|
// Drop vouchers already reconciled to a transaction or an invoice payment —
|
|
// those aren't orphans. Both lookups are filtered by company_id (defense in
|
|
// depth alongside RLS).
|
|
const entryIds = candidates.map((l) => l.journal_entry.id)
|
|
const [{ data: txLinks }, { data: payLinks }] = await Promise.all([
|
|
supabase.from('transactions').select('journal_entry_id').eq('company_id', companyId).in('journal_entry_id', entryIds),
|
|
supabase.from('invoice_payments').select('journal_entry_id').eq('company_id', companyId).in('journal_entry_id', entryIds),
|
|
])
|
|
const linked = new Set<string>()
|
|
for (const r of (txLinks ?? []) as { journal_entry_id: string | null }[]) {
|
|
if (r.journal_entry_id) linked.add(r.journal_entry_id)
|
|
}
|
|
for (const r of (payLinks ?? []) as { journal_entry_id: string | null }[]) {
|
|
if (r.journal_entry_id) linked.add(r.journal_entry_id)
|
|
}
|
|
|
|
const unlinked = candidates.filter((l) => !linked.has(l.journal_entry.id))
|
|
if (unlinked.length === 0) return null
|
|
|
|
unlinked.sort((a, b) => {
|
|
const ad = Math.abs(new Date(a.journal_entry.entry_date).getTime() - dateMs)
|
|
const bd = Math.abs(new Date(b.journal_entry.entry_date).getTime() - dateMs)
|
|
if (ad !== bd) return ad - bd
|
|
return a.journal_entry.id.localeCompare(b.journal_entry.id)
|
|
})
|
|
const best = unlinked[0]
|
|
|
|
return {
|
|
transaction_id: null,
|
|
journal_entry_id: best.journal_entry.id,
|
|
voucher_label: `${best.journal_entry.voucher_series ?? 'A'}${best.journal_entry.voucher_number ?? ''}`,
|
|
entry_date: best.journal_entry.entry_date,
|
|
description: best.journal_entry.description,
|
|
amount: roundOre(Number(inbound ? best.debit_amount : best.credit_amount)),
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Unified booking-time duplicate guard. Returns the single best already-booked
|
|
* candidate for this bank line — a sibling transaction first (the cheaper,
|
|
* higher-confidence signal), then a ledger-only voucher. Null when neither
|
|
* fires. This is the function every booking chokepoint should call (web /book +
|
|
* /categorize routes and the agent commit executors) so all paths reject the
|
|
* same double-bookings.
|
|
*/
|
|
export async function detectBookingDuplicate(
|
|
supabase: SupabaseClient,
|
|
companyId: string,
|
|
target: BookingTarget,
|
|
): Promise<BookedDuplicateCandidate | null> {
|
|
const sibling = await detectBookedDuplicateTransaction(supabase, companyId, target)
|
|
if (sibling) return sibling
|
|
return detectLedgerDuplicateVoucher(supabase, companyId, target)
|
|
}
|