From 2fdb75d9890101d161cd0d5aafcbced14647b23e Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Wed, 14 Oct 2026 20:17:57 -0400 Subject: [PATCH 1/7] fix(expenses): guard aggregate amount ranges Fixes #1059 --- main/services/expenses.ts | 854 +++++++++----- tests/expense-api.test.ts | 849 ++++++++++++-- tests/expense-records.test.ts | 1992 ++++++++++++++++++++++++++++----- 3 files changed, 3049 insertions(+), 646 deletions(-) diff --git a/main/services/expenses.ts b/main/services/expenses.ts index abe1e7338..069dd7c26 100644 --- a/main/services/expenses.ts +++ b/main/services/expenses.ts @@ -10,7 +10,12 @@ */ import { createHash, randomUUID } from 'crypto'; import { - getDatabase, getSettingValue, localDateInTimezone, now, tenantBusinessDayStartTime, withTxn, + getDatabase, + getSettingValue, + localDateInTimezone, + now, + tenantBusinessDayStartTime, + withTxn, } from '../db'; import { resolveRegionalSnapshot } from '../countries'; import { getOpenSession, type CashSessionRow } from './shift-session-gate'; @@ -22,7 +27,12 @@ export type ExpensePaymentMethod = 'cash' | 'card' | 'bank_transfer' | 'other'; export type ExpenseStatus = 'active' | 'voided' | 'replaced'; export type ExpenseStatusFilter = ExpenseStatus | 'all'; -export const EXPENSE_STATUS_FILTERS = ['active', 'voided', 'replaced', 'all'] as const satisfies readonly ExpenseStatusFilter[]; +export const EXPENSE_STATUS_FILTERS = [ + 'active', + 'voided', + 'replaced', + 'all', +] as const satisfies readonly ExpenseStatusFilter[]; export const EXPENSE_LIST_DEFAULT_LIMIT = 50; export const EXPENSE_LIST_MAX_LIMIT = 100; @@ -178,7 +188,10 @@ export function canonicalizeExpenseRequest(value: unknown): string { if (value && typeof value === 'object') { const entries = Object.keys(value as Record) .sort() - .map((key) => `${JSON.stringify(key)}:${canonicalizeExpenseRequest((value as Record)[key])}`); + .map( + (key) => + `${JSON.stringify(key)}:${canonicalizeExpenseRequest((value as Record)[key])}`, + ); return `{${entries.join(',')}}`; } if (value === undefined) return 'undefined'; @@ -226,7 +239,11 @@ export function isValidBusinessDate(value: string): boolean { const [year, month, day] = value.split('-').map(Number); if (month < 1 || month > 12 || day < 1) return false; const parsed = new Date(Date.UTC(year, month - 1, day)); - return parsed.getUTCFullYear() === year && parsed.getUTCMonth() === month - 1 && parsed.getUTCDate() === day; + return ( + parsed.getUTCFullYear() === year && + parsed.getUTCMonth() === month - 1 && + parsed.getUTCDate() === day + ); } export function requireBusinessDate(value: unknown, field: string): string { @@ -258,11 +275,43 @@ export function currentBusinessDate(): string { return localDateInTimezone(new Date(), regional.timezone, tenantBusinessDayStartTime()); } -function requireSafeTotal(value: number, label: string): number { - if (!Number.isSafeInteger(value)) { - throw new ExpenseServiceError(500, `${label} exceeds the supported amount range`); +const MAX_SAFE_TOTAL = BigInt(Number.MAX_SAFE_INTEGER); +const MIN_SAFE_TOTAL = BigInt(Number.MIN_SAFE_INTEGER); + +function unsupportedAmountRange(label: string): ExpenseServiceError { + return new ExpenseServiceError( + 422, + `${label} exceeds the supported amount range`, + 'amount_range_exceeded', + ); +} + +function isSqliteIntegerOverflow(error: unknown): boolean { + if (typeof error !== 'object' || error === null) return false; + return /integer overflow/i.test(String((error as { message?: unknown }).message || '')); +} + +function runAggregateQuery(query: () => T, label: string): T { + try { + return query(); + } catch (error) { + if (isSqliteIntegerOverflow(error)) throw unsupportedAmountRange(label); + throw error; } - return value; +} + +function toBigIntTotal(value: bigint | number, label: string): bigint { + if (typeof value === 'bigint') return value; + if (!Number.isSafeInteger(value)) throw unsupportedAmountRange(label); + return BigInt(value); +} + +function requireSafeTotal(value: bigint | number, label: string): number { + const total = toBigIntTotal(value, label); + if (total < MIN_SAFE_TOTAL || total > MAX_SAFE_TOTAL) { + throw unsupportedAmountRange(label); + } + return Number(total); } // ── Cursors ────────────────────────────────────────────────────────────────── @@ -291,106 +340,146 @@ export function listExpenseCategories( db: ExpenseDb, filters: { includeInactive?: boolean } = {}, ): { categories: ExpenseCategoryRecord[]; truncated: boolean } { - const rows = db.prepare(` + const rows = db + .prepare(` SELECT * FROM expense_categories ${filters.includeInactive ? '' : 'WHERE is_active = 1'} ORDER BY is_active DESC, name COLLATE NOCASE, id LIMIT ? - `).all(EXPENSE_CATEGORY_LIST_LIMIT + 1) as ExpenseCategoryRecord[]; + `) + .all(EXPENSE_CATEGORY_LIST_LIMIT + 1) as ExpenseCategoryRecord[]; const truncated = rows.length > EXPENSE_CATEGORY_LIST_LIMIT; return { categories: truncated ? rows.slice(0, EXPENSE_CATEGORY_LIST_LIMIT) : rows, truncated }; } export function getExpenseCategory(db: ExpenseDb, id: string): ExpenseCategoryRecord { - const row = db.prepare('SELECT * FROM expense_categories WHERE id = ?').get(id) as ExpenseCategoryRecord | undefined; + const row = db.prepare('SELECT * FROM expense_categories WHERE id = ?').get(id) as + | ExpenseCategoryRecord + | undefined; if (!row) throw new ExpenseServiceError(404, 'Expense category not found'); return row; } function isCategoryNameConflict(error: unknown): boolean { - return typeof error === 'object' && error !== null - && String((error as { code?: unknown }).code || '').includes('CONSTRAINT') - && String((error as { message?: unknown }).message || '').includes('expense_categories.name_key'); + return ( + typeof error === 'object' && + error !== null && + String((error as { code?: unknown }).code || '').includes('CONSTRAINT') && + String((error as { message?: unknown }).message || '').includes('expense_categories.name_key') + ); } export function createExpenseCategory( db: ExpenseDb, - input: { name?: unknown; is_active?: unknown; actorUserId: string; idempotencyKey?: string | null }, + input: { + name?: unknown; + is_active?: unknown; + actorUserId: string; + idempotencyKey?: string | null; + }, ): MutationOutcome<{ category: ExpenseCategoryRecord }> { const { name, nameKey } = normalizeCategoryName(input.name); - const isActive = input.is_active === undefined ? true : requireBoolean(input.is_active, 'is_active'); + const isActive = + input.is_active === undefined ? true : requireBoolean(input.is_active, 'is_active'); const id = `expcat_${randomUUID()}`; - return withTxn(() => runExpenseMutation( - db, - { - actorUserId: input.actorUserId, - idempotencyKey: input.idempotencyKey ?? null, - operation: 'create_expense_category', - resourceId: '', - fields: { name: nameKey, is_active: isActive ? 1 : 0 }, - }, - () => { - const timestamp = now(); - try { - db.prepare(` + return withTxn(() => + runExpenseMutation( + db, + { + actorUserId: input.actorUserId, + idempotencyKey: input.idempotencyKey ?? null, + operation: 'create_expense_category', + resourceId: '', + fields: { name: nameKey, is_active: isActive ? 1 : 0 }, + }, + () => { + const timestamp = now(); + try { + db.prepare(` INSERT INTO expense_categories (id, name, name_key, is_active, created_by, updated_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) - `).run(id, name, nameKey, isActive ? 1 : 0, input.actorUserId, input.actorUserId, timestamp, timestamp); - } catch (error) { - if (isCategoryNameConflict(error)) throw conflict('An expense category with this name already exists', 'category_name_taken'); - throw error; - } - return { status: 201, body: { category: getExpenseCategory(db, id) } }; - }, - )); + `).run( + id, + name, + nameKey, + isActive ? 1 : 0, + input.actorUserId, + input.actorUserId, + timestamp, + timestamp, + ); + } catch (error) { + if (isCategoryNameConflict(error)) + throw conflict( + 'An expense category with this name already exists', + 'category_name_taken', + ); + throw error; + } + return { status: 201, body: { category: getExpenseCategory(db, id) } }; + }, + ), + ); } export function updateExpenseCategory( db: ExpenseDb, id: string, - input: { name?: unknown; is_active?: unknown; actorUserId: string; idempotencyKey?: string | null }, + input: { + name?: unknown; + is_active?: unknown; + actorUserId: string; + idempotencyKey?: string | null; + }, ): MutationOutcome<{ category: ExpenseCategoryRecord }> { if (input.name === undefined && input.is_active === undefined) { throw badRequest('name or is_active is required'); } const namePatch = input.name === undefined ? null : normalizeCategoryName(input.name); - const isActivePatch = input.is_active === undefined ? null : requireBoolean(input.is_active, 'is_active'); - - return withTxn(() => runExpenseMutation( - db, - { - actorUserId: input.actorUserId, - idempotencyKey: input.idempotencyKey ?? null, - operation: 'update_expense_category', - resourceId: id, - fields: { - name: namePatch ? namePatch.nameKey : null, - is_active: isActivePatch === null ? null : (isActivePatch ? 1 : 0), + const isActivePatch = + input.is_active === undefined ? null : requireBoolean(input.is_active, 'is_active'); + + return withTxn(() => + runExpenseMutation( + db, + { + actorUserId: input.actorUserId, + idempotencyKey: input.idempotencyKey ?? null, + operation: 'update_expense_category', + resourceId: id, + fields: { + name: namePatch ? namePatch.nameKey : null, + is_active: isActivePatch === null ? null : isActivePatch ? 1 : 0, + }, }, - }, - () => { - const category = getExpenseCategory(db, id); - const nextName = namePatch ? namePatch.name : category.name; - const nextActive = isActivePatch === null ? category.is_active : (isActivePatch ? 1 : 0); - try { - if (namePatch) { - db.prepare(` + () => { + const category = getExpenseCategory(db, id); + const nextName = namePatch ? namePatch.name : category.name; + const nextActive = isActivePatch === null ? category.is_active : isActivePatch ? 1 : 0; + try { + if (namePatch) { + db.prepare(` UPDATE expense_categories SET name = ?, name_key = ?, is_active = ?, updated_by = ?, updated_at = ? WHERE id = ? `).run(nextName, namePatch.nameKey, nextActive, input.actorUserId, now(), id); - } else { - db.prepare(` + } else { + db.prepare(` UPDATE expense_categories SET is_active = ?, updated_by = ?, updated_at = ? WHERE id = ? `).run(nextActive, input.actorUserId, now(), id); + } + } catch (error) { + if (isCategoryNameConflict(error)) + throw conflict( + 'An expense category with this name already exists', + 'category_name_taken', + ); + throw error; } - } catch (error) { - if (isCategoryNameConflict(error)) throw conflict('An expense category with this name already exists', 'category_name_taken'); - throw error; - } - return { status: 200, body: { category: getExpenseCategory(db, id) } }; - }, - )); + return { status: 200, body: { category: getExpenseCategory(db, id) } }; + }, + ), + ); } // ── Expense reads ──────────────────────────────────────────────────────────── @@ -408,9 +497,16 @@ const EXPENSE_PROJECTION = ` ), 0) AS net_paid_minor `; -function projectExpenseRow(row: ExpenseRecord & { net_paid_minor: number }): ExpenseRecord { - const paidMinor = requireSafeTotal(Number(row.net_paid_minor), 'Expense paid total'); - const amountMinor = Number(row.amount_minor); +interface RawExpenseRow extends Omit { + amount_minor: bigint | number; + net_paid_minor: bigint | number; +} + +function projectExpenseRow(row: RawExpenseRow): ExpenseRecord { + const amountRaw = toBigIntTotal(row.amount_minor, 'Expense amount'); + const paidRaw = toBigIntTotal(row.net_paid_minor, 'Expense paid total'); + const amountMinor = requireSafeTotal(amountRaw, 'Expense amount'); + const paidMinor = requireSafeTotal(paidRaw, 'Expense paid total'); return { id: row.id, category_id: row.category_id, @@ -429,7 +525,7 @@ function projectExpenseRow(row: ExpenseRecord & { net_paid_minor: number }): Exp void_reason: row.void_reason, status: row.status, paid_minor: paidMinor, - due_minor: requireSafeTotal(amountMinor - paidMinor, 'Expense due total'), + due_minor: requireSafeTotal(amountRaw - paidRaw, 'Expense due total'), }; } @@ -448,8 +544,12 @@ interface ExpenseFilterSql { function buildExpenseFilterSql(filters: ExpenseListFilters): ExpenseFilterSql { const conditions: string[] = []; const params: (string | number)[] = []; - const from = filters.from === undefined || filters.from === null ? null : requireBusinessDate(filters.from, 'from'); - const to = filters.to === undefined || filters.to === null ? null : requireBusinessDate(filters.to, 'to'); + const from = + filters.from === undefined || filters.from === null + ? null + : requireBusinessDate(filters.from, 'from'); + const to = + filters.to === undefined || filters.to === null ? null : requireBusinessDate(filters.to, 'to'); if (from && to && from > to) throw badRequest('from must not be after to'); if (from) { conditions.push('e.incurred_on >= ?'); @@ -475,10 +575,13 @@ function buildExpenseFilterSql(filters: ExpenseListFilters): ExpenseFilterSql { } if (status === 'active') conditions.push('e.voided_at IS NULL'); else if (status === 'voided') { - conditions.push('e.voided_at IS NOT NULL AND NOT EXISTS (SELECT 1 FROM expenses r WHERE r.replaces_expense_id = e.id)'); - } - else if (status === 'replaced') { - conditions.push('e.voided_at IS NOT NULL AND EXISTS (SELECT 1 FROM expenses r WHERE r.replaces_expense_id = e.id)'); + conditions.push( + 'e.voided_at IS NOT NULL AND NOT EXISTS (SELECT 1 FROM expenses r WHERE r.replaces_expense_id = e.id)', + ); + } else if (status === 'replaced') { + conditions.push( + 'e.voided_at IS NOT NULL AND EXISTS (SELECT 1 FROM expenses r WHERE r.replaces_expense_id = e.id)', + ); } return { whereSql: conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '', @@ -487,10 +590,14 @@ function buildExpenseFilterSql(filters: ExpenseListFilters): ExpenseFilterSql { }; } -export function normalizeExpenseLimit(value: unknown, fallback = EXPENSE_LIST_DEFAULT_LIMIT): number { +export function normalizeExpenseLimit( + value: unknown, + fallback = EXPENSE_LIST_DEFAULT_LIMIT, +): number { if (value === undefined || value === null || value === '') return fallback; const parsed = typeof value === 'number' ? value : Number(value); - if (!Number.isInteger(parsed) || parsed <= 0) throw badRequest('limit must be a positive integer'); + if (!Number.isInteger(parsed) || parsed <= 0) + throw badRequest('limit must be a positive integer'); return Math.min(parsed, EXPENSE_LIST_MAX_LIMIT); } @@ -503,18 +610,21 @@ export function listExpenses( const cursor = filters.cursor ? decodeCursor(String(filters.cursor), 2) : null; if (cursor && !isValidBusinessDate(cursor[0])) throw badRequest('cursor is invalid'); const cursorSql = cursor ? `${whereSql ? 'AND' : 'WHERE'} (e.incurred_on, e.id) < (?, ?)` : ''; - const rows = db.prepare(` - SELECT ${EXPENSE_PROJECTION} - FROM expenses e - ${whereSql} - ${cursorSql} - ORDER BY e.incurred_on DESC, e.id DESC - LIMIT ? - `).all( - ...params, - ...(cursor ? [cursor[0], cursor[1]] : []), - limit + 1, - ) as (ExpenseRecord & { net_paid_minor: number })[]; + const rows = runAggregateQuery( + () => + db + .prepare(` + SELECT ${EXPENSE_PROJECTION} + FROM expenses e + ${whereSql} + ${cursorSql} + ORDER BY e.incurred_on DESC, e.id DESC + LIMIT ? + `) + .safeIntegers(true) + .all(...params, ...(cursor ? [cursor[0], cursor[1]] : []), limit + 1) as RawExpenseRow[], + 'Expense paid total', + ); const hasMore = rows.length > limit; const page = hasMore ? rows.slice(0, limit) : rows; const last = page[page.length - 1]; @@ -526,9 +636,14 @@ export function listExpenses( } export function getExpense(db: ExpenseDb, id: string): ExpenseRecord { - const row = db.prepare(`SELECT ${EXPENSE_PROJECTION} FROM expenses e WHERE e.id = ?`).get(id) as - | (ExpenseRecord & { net_paid_minor: number }) - | undefined; + const row = runAggregateQuery( + () => + db + .prepare(`SELECT ${EXPENSE_PROJECTION} FROM expenses e WHERE e.id = ?`) + .safeIntegers(true) + .get(id) as RawExpenseRow | undefined, + 'Expense paid total', + ); if (!row) throw new ExpenseServiceError(404, 'Expense not found'); return projectExpenseRow(row); } @@ -542,12 +657,17 @@ export function listExpensePayments( const limit = normalizeExpenseLimit(options.limit); const cursor = options.cursor ? decodeCursor(String(options.cursor), 2) : null; if (cursor && !DB_TIMESTAMP_PATTERN.test(cursor[0])) throw badRequest('cursor is invalid'); - const rows = db.prepare(` + const rows = db + .prepare(` SELECT * FROM expense_payments p ${cursor ? 'WHERE p.expense_id = ? AND (p.created_at, p.id) < (?, ?)' : 'WHERE p.expense_id = ?'} ORDER BY p.created_at DESC, p.id DESC LIMIT ? - `).all(...(cursor ? [expenseId, cursor[0], cursor[1]] : [expenseId]), limit + 1) as ExpensePaymentRecord[]; + `) + .all( + ...(cursor ? [expenseId, cursor[0], cursor[1]] : [expenseId]), + limit + 1, + ) as ExpensePaymentRecord[]; const hasMore = rows.length > limit; const page = hasMore ? rows.slice(0, limit) : rows; const last = page[page.length - 1]; @@ -561,28 +681,39 @@ interface ExpenseAggregateRow { currency_code: string; category_id?: string; category_name?: string; - expense_count: number; - incurred_minor: number; - net_paid_minor: number; + expense_count: bigint | number; + incurred_minor: bigint | number; + net_paid_minor: bigint | number; } -function aggregateExpenses(db: ExpenseDb, filter: ExpenseFilterSql, grouped: boolean): ExpenseAggregateRow[] { - return db.prepare(` - SELECT - e.currency_code AS currency_code, - ${grouped ? 'e.category_id AS category_id, MAX(COALESCE(c.name, e.category_name)) AS category_name,' : ''} - COUNT(*) AS expense_count, - COALESCE(SUM(e.amount_minor), 0) AS incurred_minor, - COALESCE(SUM(COALESCE(( - SELECT SUM(CASE WHEN p.reversal_of IS NULL THEN p.amount_minor ELSE -p.amount_minor END) - FROM expense_payments p WHERE p.expense_id = e.id - ), 0)), 0) AS net_paid_minor - FROM expenses e - LEFT JOIN expense_categories c ON c.id = e.category_id - ${filter.whereSql} - GROUP BY e.currency_code${grouped ? ', e.category_id' : ''} - ORDER BY e.currency_code${grouped ? ', MAX(COALESCE(c.name, e.category_name)) COLLATE NOCASE' : ''} - `).all(...filter.params) as ExpenseAggregateRow[]; +function aggregateExpenses( + db: ExpenseDb, + filter: ExpenseFilterSql, + grouped: boolean, +): ExpenseAggregateRow[] { + return runAggregateQuery( + () => + db + .prepare(` + SELECT + e.currency_code AS currency_code, + ${grouped ? 'e.category_id AS category_id, MAX(COALESCE(c.name, e.category_name)) AS category_name,' : ''} + COUNT(*) AS expense_count, + COALESCE(SUM(e.amount_minor), 0) AS incurred_minor, + COALESCE(SUM(COALESCE(( + SELECT SUM(CASE WHEN p.reversal_of IS NULL THEN p.amount_minor ELSE -p.amount_minor END) + FROM expense_payments p WHERE p.expense_id = e.id + ), 0)), 0) AS net_paid_minor + FROM expenses e + LEFT JOIN expense_categories c ON c.id = e.category_id + ${filter.whereSql} + GROUP BY e.currency_code${grouped ? ', e.category_id' : ''} + ORDER BY e.currency_code${grouped ? ', MAX(COALESCE(c.name, e.category_name)) COLLATE NOCASE' : ''} + `) + .safeIntegers(true) + .all(...filter.params) as ExpenseAggregateRow[], + 'Expense incurred total', + ); } /** @@ -592,27 +723,33 @@ function aggregateExpenses(db: ExpenseDb, filter: ExpenseFilterSql, grouped: boo export function summarizeExpenses(db: ExpenseDb, filters: ExpenseListFilters = {}): ExpenseSummary { const filter = buildExpenseFilterSql({ ...filters, status: 'active' }); const groups = aggregateExpenses(db, filter, true).map((row) => { - const incurredMinor = requireSafeTotal(Number(row.incurred_minor), 'Expense incurred total'); - const netPaidMinor = requireSafeTotal(Number(row.net_paid_minor), 'Expense paid total'); + const expenseCount = requireSafeTotal(row.expense_count, 'Expense count'); + const incurredRaw = toBigIntTotal(row.incurred_minor, 'Expense incurred total'); + const netPaidRaw = toBigIntTotal(row.net_paid_minor, 'Expense paid total'); + const incurredMinor = requireSafeTotal(incurredRaw, 'Expense incurred total'); + const netPaidMinor = requireSafeTotal(netPaidRaw, 'Expense paid total'); return { currency_code: row.currency_code, category_id: String(row.category_id), category_name: String(row.category_name), - expense_count: Number(row.expense_count), + expense_count: expenseCount, incurred_minor: incurredMinor, net_paid_minor: netPaidMinor, - due_minor: requireSafeTotal(incurredMinor - netPaidMinor, 'Expense due total'), + due_minor: requireSafeTotal(incurredRaw - netPaidRaw, 'Expense due total'), }; }); const totals = aggregateExpenses(db, filter, false).map((row) => { - const incurredMinor = requireSafeTotal(Number(row.incurred_minor), 'Expense incurred total'); - const netPaidMinor = requireSafeTotal(Number(row.net_paid_minor), 'Expense paid total'); + const expenseCount = requireSafeTotal(row.expense_count, 'Expense count'); + const incurredRaw = toBigIntTotal(row.incurred_minor, 'Expense incurred total'); + const netPaidRaw = toBigIntTotal(row.net_paid_minor, 'Expense paid total'); + const incurredMinor = requireSafeTotal(incurredRaw, 'Expense incurred total'); + const netPaidMinor = requireSafeTotal(netPaidRaw, 'Expense paid total'); return { currency_code: row.currency_code, - expense_count: Number(row.expense_count), + expense_count: expenseCount, incurred_minor: incurredMinor, net_paid_minor: netPaidMinor, - due_minor: requireSafeTotal(incurredMinor - netPaidMinor, 'Expense due total'), + due_minor: requireSafeTotal(incurredRaw - netPaidRaw, 'Expense due total'), }; }); return { @@ -639,28 +776,44 @@ export function summarizeExpenses(db: ExpenseDb, filters: ExpenseListFilters = { */ function runExpenseMutation( db: ExpenseDb, - input: { actorUserId: string; idempotencyKey: string | null; operation: string; resourceId: string; fields: Record }, + input: { + actorUserId: string; + idempotencyKey: string | null; + operation: string; + resourceId: string; + fields: Record; + }, work: () => { status: number; body: T }, ): MutationOutcome { const key = input.idempotencyKey; const requestHash = (body: T) => { const currency = (body as { expense?: ExpenseRecord }).expense?.currency_code; - const fields = input.fields.currency_code === null && currency - ? { ...input.fields, currency_code: currency } - : input.fields; + const fields = + input.fields.currency_code === null && currency + ? { ...input.fields, currency_code: currency } + : input.fields; return expenseRequestHash(input.operation, input.resourceId, fields); }; if (key) { - const existing = db.prepare(` + const existing = db + .prepare(` SELECT operation, resource_id, request_hash, response_json FROM expense_mutations WHERE actor_user_id = ? AND idempotency_key = ? - `).get(input.actorUserId, key) as + `) + .get(input.actorUserId, key) as | { operation: string; resource_id: string; request_hash: string; response_json: string } | undefined; if (existing) { const stored = JSON.parse(existing.response_json) as { status: number; body: T }; - if (existing.operation !== input.operation || existing.resource_id !== input.resourceId || existing.request_hash !== requestHash(stored.body)) { - throw conflict('Idempotency-Key was already used for a different expense request', 'idempotency_conflict'); + if ( + existing.operation !== input.operation || + existing.resource_id !== input.resourceId || + existing.request_hash !== requestHash(stored.body) + ) { + throw conflict( + 'Idempotency-Key was already used for a different expense request', + 'idempotency_conflict', + ); } return { status: stored.status, body: stored.body, replayed: true }; } @@ -692,7 +845,8 @@ function requireActiveCategory(db: ExpenseDb, categoryId: string): ExpenseCatego /** The store currency at creation time; expenses never carry a foreign snapshot. */ function resolveExpenseCurrency(currencyCode: unknown): string { const storeCurrency = tenantRegionalSnapshot().currency; - if (currencyCode === undefined || currencyCode === null || currencyCode === '') return storeCurrency; + if (currencyCode === undefined || currencyCode === null || currencyCode === '') + return storeCurrency; const requested = requireCurrencyCode(currencyCode); if (requested !== storeCurrency) { throw badRequest(`currency_code must match the store currency (${storeCurrency})`); @@ -762,26 +916,114 @@ function insertExpense( /** Net paid across the immutable ledger: payments minus their reversals. */ function netPaidMinor(db: ExpenseDb, expenseId: string): number { - const row = db.prepare(` - SELECT COALESCE(SUM(CASE WHEN reversal_of IS NULL THEN amount_minor ELSE -amount_minor END), 0) AS net_paid - FROM expense_payments WHERE expense_id = ? - `).get(expenseId) as { net_paid: number }; - return requireSafeTotal(Number(row.net_paid), 'Expense paid total'); + const row = runAggregateQuery( + () => + db + .prepare(` + SELECT COALESCE(SUM(CASE WHEN reversal_of IS NULL THEN amount_minor ELSE -amount_minor END), 0) AS net_paid + FROM expense_payments WHERE expense_id = ? + `) + .safeIntegers(true) + .get(expenseId) as { net_paid: bigint | number }, + 'Expense paid total', + ); + return requireSafeTotal(row.net_paid, 'Expense paid total'); } /** Only an open (not voided, not replaced) expense may change its ledger. */ function requireActiveExpense(db: ExpenseDb, id: string): ExpenseRecord { const expense = getExpense(db, id); - if (expense.status === 'replaced') throw conflict('Expense was already replaced', 'expense_replaced'); + if (expense.status === 'replaced') + throw conflict('Expense was already replaced', 'expense_replaced'); if (expense.voided_at) throw conflict('Expense is already voided', 'expense_voided'); return expense; } -function requireUnpaidOpenExpense(db: ExpenseDb, id: string): void { - requireActiveExpense(db, id); +interface ActiveExpenseAmountRow { + id: string; + amount_minor: bigint; + currency_code: string; +} + +function requireUnpaidOpenExpense(db: ExpenseDb, id: string): ActiveExpenseAmountRow { + const row = db + .prepare(` + SELECT + e.id, + e.amount_minor, + e.currency_code, + e.voided_at, + CASE + WHEN e.voided_at IS NULL THEN 'active' + WHEN EXISTS (SELECT 1 FROM expenses r WHERE r.replaces_expense_id = e.id) THEN 'replaced' + ELSE 'voided' + END AS status + FROM expenses e + WHERE e.id = ? + `) + .safeIntegers(true) + .get(id) as + | (ActiveExpenseAmountRow & { voided_at: string | null; status: ExpenseStatus }) + | undefined; + if (!row) throw new ExpenseServiceError(404, 'Expense not found'); + if (row.status === 'replaced') throw conflict('Expense was already replaced', 'expense_replaced'); + if (row.voided_at) throw conflict('Expense is already voided', 'expense_voided'); if (netPaidMinor(db, id) !== 0) { - throw conflict('Expense has recorded payments; reverse them before voiding or replacing', 'expense_has_payments'); + throw conflict( + 'Expense has recorded payments; reverse them before voiding or replacing', + 'expense_has_payments', + ); } + return { + id: row.id, + amount_minor: toBigIntTotal(row.amount_minor, 'Expense amount'), + currency_code: row.currency_code, + }; +} + +function ensureSupportedActiveCurrencyTotal( + db: ExpenseDb, + currencyCode: string, + amountMinor: number, + replaced?: ActiveExpenseAmountRow, +): void { + const replacedMinor = + replaced && replaced.currency_code === currencyCode ? replaced.amount_minor : 0n; + let currentActiveMinor: bigint; + try { + const row = db + .prepare(` + SELECT COALESCE(SUM(amount_minor), 0) AS incurred_minor + FROM expenses + WHERE voided_at IS NULL AND currency_code = ? + `) + .safeIntegers(true) + .get(currencyCode) as { incurred_minor: bigint | number }; + currentActiveMinor = + toBigIntTotal(row.incurred_minor, 'Expense incurred total') - replacedMinor; + } catch (error) { + if (isSqliteIntegerOverflow(error) && replaced && replaced.currency_code === currencyCode) { + const row = runAggregateQuery( + () => + db + .prepare(` + SELECT COALESCE(SUM(amount_minor), 0) AS incurred_minor + FROM expenses + WHERE voided_at IS NULL AND currency_code = ? AND id != ? + `) + .safeIntegers(true) + .get(currencyCode, replaced.id) as { incurred_minor: bigint | number }, + 'Expense incurred total', + ); + currentActiveMinor = toBigIntTotal(row.incurred_minor, 'Expense incurred total'); + } else if (isSqliteIntegerOverflow(error)) { + throw unsupportedAmountRange('Expense incurred total'); + } else { + throw error; + } + } + const nextActiveMinor = currentActiveMinor + BigInt(amountMinor); + requireSafeTotal(nextActiveMinor, 'Expense incurred total'); } function requireReason(value: unknown): string { @@ -789,16 +1031,18 @@ function requireReason(value: unknown): string { } function expenseMutationFields(input: ExpenseWriteFields): Record { - const normalizeText = (value: unknown) => typeof value === 'string' ? value.trim() : value; + const normalizeText = (value: unknown) => (typeof value === 'string' ? value.trim() : value); const normalizeOptionalText = (value: unknown) => { if (value === undefined || value === null) return null; const normalized = normalizeText(value); return normalized === '' ? null : normalized; }; - const rawCurrency = typeof input.currency_code === 'string' ? input.currency_code.trim().toUpperCase() : input.currency_code; - const canonicalCurrency = rawCurrency === undefined || rawCurrency === null || rawCurrency === '' - ? null - : rawCurrency; + const rawCurrency = + typeof input.currency_code === 'string' + ? input.currency_code.trim().toUpperCase() + : input.currency_code; + const canonicalCurrency = + rawCurrency === undefined || rawCurrency === null || rawCurrency === '' ? null : rawCurrency; return { category_id: normalizeText(input.category_id), description: normalizeText(input.description), @@ -818,21 +1062,24 @@ export function createExpense( if (input.replaces_expense_id !== undefined && input.replaces_expense_id !== null) { throw badRequest('replaces_expense_id can only be set by the replace operation'); } - return withTxn(() => runExpenseMutation( - db, - { - actorUserId: input.actorUserId, - idempotencyKey: input.idempotencyKey ?? null, - operation: 'create_expense', - resourceId: '', - fields: expenseMutationFields(input), - }, - () => { - const fields = validateExpenseFields(db, input); - const id = insertExpense(db, fields, input.actorUserId, null); - return { status: 201, body: { expense: getExpense(db, id) } }; - }, - )); + return withTxn(() => + runExpenseMutation( + db, + { + actorUserId: input.actorUserId, + idempotencyKey: input.idempotencyKey ?? null, + operation: 'create_expense', + resourceId: '', + fields: expenseMutationFields(input), + }, + () => { + const fields = validateExpenseFields(db, input); + ensureSupportedActiveCurrencyTotal(db, fields.currencyCode, fields.amountMinor); + const id = insertExpense(db, fields, input.actorUserId, null); + return { status: 201, body: { expense: getExpense(db, id) } }; + }, + ), + ); } /** Keeps the original expense row, values and audit trail; only adds void metadata. */ @@ -842,49 +1089,63 @@ export function voidExpense( input: { reason?: unknown; actorUserId: string; idempotencyKey?: string | null }, ): MutationOutcome<{ expense: ExpenseRecord }> { const reason = requireReason(input.reason); - return withTxn(() => runExpenseMutation( - db, - { - actorUserId: input.actorUserId, - idempotencyKey: input.idempotencyKey ?? null, - operation: 'void_expense', - resourceId: id, - fields: { reason }, - }, - () => { - requireUnpaidOpenExpense(db, id); - db.prepare('UPDATE expenses SET voided_at = ?, voided_by = ?, void_reason = ? WHERE id = ?') - .run(now(), input.actorUserId, reason, id); - return { status: 200, body: { expense: getExpense(db, id) } }; - }, - )); + return withTxn(() => + runExpenseMutation( + db, + { + actorUserId: input.actorUserId, + idempotencyKey: input.idempotencyKey ?? null, + operation: 'void_expense', + resourceId: id, + fields: { reason }, + }, + () => { + requireUnpaidOpenExpense(db, id); + db.prepare( + 'UPDATE expenses SET voided_at = ?, voided_by = ?, void_reason = ? WHERE id = ?', + ).run(now(), input.actorUserId, reason, id); + return { status: 200, body: { expense: getExpense(db, id) } }; + }, + ), + ); } /** Voids the source and creates the linked replacement in one transaction. */ export function replaceExpense( db: ExpenseDb, id: string, - input: ExpenseWriteFields & { reason?: unknown; actorUserId: string; idempotencyKey?: string | null }, + input: ExpenseWriteFields & { + reason?: unknown; + actorUserId: string; + idempotencyKey?: string | null; + }, ): MutationOutcome<{ expense: ExpenseRecord; replaced_expense_id: string }> { const reason = requireReason(input.reason); - return withTxn(() => runExpenseMutation( - db, - { - actorUserId: input.actorUserId, - idempotencyKey: input.idempotencyKey ?? null, - operation: 'replace_expense', - resourceId: id, - fields: { reason, ...expenseMutationFields(input) }, - }, - () => { - requireUnpaidOpenExpense(db, id); - const fields = validateExpenseFields(db, input); - const replacementId = insertExpense(db, fields, input.actorUserId, id); - db.prepare('UPDATE expenses SET voided_at = ?, voided_by = ?, void_reason = ? WHERE id = ?') - .run(now(), input.actorUserId, reason, id); - return { status: 201, body: { expense: getExpense(db, replacementId), replaced_expense_id: id } }; - }, - )); + return withTxn(() => + runExpenseMutation( + db, + { + actorUserId: input.actorUserId, + idempotencyKey: input.idempotencyKey ?? null, + operation: 'replace_expense', + resourceId: id, + fields: { reason, ...expenseMutationFields(input) }, + }, + () => { + const source = requireUnpaidOpenExpense(db, id); + const fields = validateExpenseFields(db, input); + ensureSupportedActiveCurrencyTotal(db, fields.currencyCode, fields.amountMinor, source); + const replacementId = insertExpense(db, fields, input.actorUserId, id); + db.prepare( + 'UPDATE expenses SET voided_at = ?, voided_by = ?, void_reason = ? WHERE id = ?', + ).run(now(), input.actorUserId, reason, id); + return { + status: 201, + body: { expense: getExpense(db, replacementId), replaced_expense_id: id }, + }; + }, + ), + ); } // ── Payment and reversal writes ────────────────────────────────────────────── @@ -899,8 +1160,13 @@ export function normalizeExpensePaymentMethod(value: unknown): ExpensePaymentMet } /** The committed ledger entry for one expense/payment pair, or a 404. */ -export function getExpensePayment(db: ExpenseDb, expenseId: string, paymentId: string): ExpensePaymentRecord { - const row = db.prepare('SELECT * FROM expense_payments WHERE id = ? AND expense_id = ?') +export function getExpensePayment( + db: ExpenseDb, + expenseId: string, + paymentId: string, +): ExpensePaymentRecord { + const row = db + .prepare('SELECT * FROM expense_payments WHERE id = ? AND expense_id = ?') .get(paymentId, expenseId) as ExpensePaymentRecord | undefined; if (!row) throw new ExpenseServiceError(404, 'Expense payment not found'); return row; @@ -921,7 +1187,10 @@ function requireExpectedCurrency(value: unknown, expected: string): void { function requireOpenSessionForCashEntry(db: ExpenseDb): CashSessionRow { const session = getOpenSession(db); if (!session) { - throw conflict('No open cash session: open a shift before moving drawer money for an expense', 'cash_session_required'); + throw conflict( + 'No open cash session: open a shift before moving drawer money for an expense', + 'cash_session_required', + ); } return session; } @@ -930,7 +1199,10 @@ function requireOpenSessionForCashEntry(db: ExpenseDb): CashSessionRow { function requireStoreCurrency(expense: ExpenseRecord): void { const storeCurrency = tenantRegionalSnapshot().currency; if (expense.currency_code !== storeCurrency) { - throw conflict(`Expense settlements require the expense currency to match the store currency (${storeCurrency})`, 'currency_mismatch'); + throw conflict( + `Expense settlements require the expense currency to match the store currency (${storeCurrency})`, + 'currency_mismatch', + ); } } @@ -946,15 +1218,25 @@ interface ExpensePaymentWriteFields { currency_code?: unknown; } -function paymentMutationFields(input: ExpensePaymentWriteFields, expenseCurrency: string): Record { - const rawCurrency = typeof input.currency_code === 'string' ? input.currency_code.trim().toUpperCase() : input.currency_code; - const canonicalCurrency = rawCurrency === undefined || rawCurrency === null || rawCurrency === '' - ? expenseCurrency - : rawCurrency; +function paymentMutationFields( + input: ExpensePaymentWriteFields, + expenseCurrency: string, +): Record { + const rawCurrency = + typeof input.currency_code === 'string' + ? input.currency_code.trim().toUpperCase() + : input.currency_code; + const canonicalCurrency = + rawCurrency === undefined || rawCurrency === null || rawCurrency === '' + ? expenseCurrency + : rawCurrency; return { amount_minor: input.amount_minor, method: input.method, - reference: typeof input.reference === 'string' ? input.reference.trim() || null : input.reference ?? null, + reference: + typeof input.reference === 'string' + ? input.reference.trim() || null + : (input.reference ?? null), currency_code: canonicalCurrency, }; } @@ -979,32 +1261,50 @@ export function recordExpensePayment( () => { requireActiveExpense(db, expenseId); const amountMinor = requirePositiveMinorUnits(input.amount_minor, 'amount_minor'); - const method = normalizeExpensePaymentMethod(input.method); - const reference = optionalTrimmedText(input.reference, 'reference', REFERENCE_LIMIT); - requireStoreCurrency(expense); - requireExpectedCurrency(input.currency_code, expense.currency_code); - if (amountMinor > expense.due_minor) throw conflict('Payment exceeds the amount due', 'expense_overpaid'); - const businessDate = currentBusinessDate(); - let cashMovementId: number | null = null; - if (method === 'cash') { - const session = requireOpenSessionForCashEntry(db); - cashMovementId = insertCashDrawerMovement(db, { - businessDate, - movementType: 'pay_out', - amountCents: amountMinor, - reason: expensePaymentReason('Expense payment', expense), - createdBy: input.actorUserId, - cashSessionId: session.id, - }); - } - const id = `exppay_${randomUUID()}`; - db.prepare(` + const method = normalizeExpensePaymentMethod(input.method); + const reference = optionalTrimmedText(input.reference, 'reference', REFERENCE_LIMIT); + requireStoreCurrency(expense); + requireExpectedCurrency(input.currency_code, expense.currency_code); + if (amountMinor > expense.due_minor) + throw conflict('Payment exceeds the amount due', 'expense_overpaid'); + const businessDate = currentBusinessDate(); + let cashMovementId: number | null = null; + if (method === 'cash') { + const session = requireOpenSessionForCashEntry(db); + cashMovementId = insertCashDrawerMovement(db, { + businessDate, + movementType: 'pay_out', + amountCents: amountMinor, + reason: expensePaymentReason('Expense payment', expense), + createdBy: input.actorUserId, + cashSessionId: session.id, + }); + } + const id = `exppay_${randomUUID()}`; + db.prepare(` INSERT INTO expense_payments ( id, expense_id, amount_minor, method, business_date, reversal_of, reason, reference, cash_movement_id, created_by, created_at ) VALUES (?, ?, ?, ?, ?, NULL, NULL, ?, ?, ?, ?) - `).run(id, expenseId, amountMinor, method, businessDate, reference, cashMovementId, input.actorUserId, now()); - return { status: 201, body: { expense: getExpense(db, expenseId), payment: getExpensePayment(db, expenseId, id) } }; - }); + `).run( + id, + expenseId, + amountMinor, + method, + businessDate, + reference, + cashMovementId, + input.actorUserId, + now(), + ); + return { + status: 201, + body: { + expense: getExpense(db, expenseId), + payment: getExpensePayment(db, expenseId, id), + }, + }; + }, + ); }); } @@ -1020,45 +1320,67 @@ export function reverseExpensePayment( input: { reason?: unknown; actorUserId: string; idempotencyKey?: string | null }, ): MutationOutcome<{ expense: ExpenseRecord; payment: ExpensePaymentRecord }> { const reason = requireReason(input.reason); - return withTxn(() => runExpenseMutation( - db, - { - actorUserId: input.actorUserId, - idempotencyKey: input.idempotencyKey ?? null, - operation: 'reverse_expense_payment', - resourceId: paymentId, - fields: { reason }, - }, - () => { - const expense = requireActiveExpense(db, expenseId); - const payment = getExpensePayment(db, expenseId, paymentId); - if (payment.reversal_of !== null) throw conflict('A reversal entry cannot be reversed', 'reversal_not_reversible'); - if (db.prepare('SELECT id FROM expense_payments WHERE reversal_of = ? LIMIT 1').get(paymentId)) { - throw conflict('This payment has already been reversed', 'payment_already_reversed'); - } - requireStoreCurrency(expense); - const businessDate = currentBusinessDate(); - let cashMovementId: number | null = null; - if (payment.method === 'cash') { - const session = requireOpenSessionForCashEntry(db); - cashMovementId = insertCashDrawerMovement(db, { - businessDate, - movementType: 'pay_in', - amountCents: payment.amount_minor, - reason: expensePaymentReason('Expense payment reversal', expense), - createdBy: input.actorUserId, - cashSessionId: session.id, - }); - } - const id = `exppay_${randomUUID()}`; - db.prepare(` + return withTxn(() => + runExpenseMutation( + db, + { + actorUserId: input.actorUserId, + idempotencyKey: input.idempotencyKey ?? null, + operation: 'reverse_expense_payment', + resourceId: paymentId, + fields: { reason }, + }, + () => { + const expense = requireActiveExpense(db, expenseId); + const payment = getExpensePayment(db, expenseId, paymentId); + if (payment.reversal_of !== null) + throw conflict('A reversal entry cannot be reversed', 'reversal_not_reversible'); + if ( + db.prepare('SELECT id FROM expense_payments WHERE reversal_of = ? LIMIT 1').get(paymentId) + ) { + throw conflict('This payment has already been reversed', 'payment_already_reversed'); + } + requireStoreCurrency(expense); + const businessDate = currentBusinessDate(); + let cashMovementId: number | null = null; + if (payment.method === 'cash') { + const session = requireOpenSessionForCashEntry(db); + cashMovementId = insertCashDrawerMovement(db, { + businessDate, + movementType: 'pay_in', + amountCents: payment.amount_minor, + reason: expensePaymentReason('Expense payment reversal', expense), + createdBy: input.actorUserId, + cashSessionId: session.id, + }); + } + const id = `exppay_${randomUUID()}`; + db.prepare(` INSERT INTO expense_payments ( id, expense_id, amount_minor, method, business_date, reversal_of, reason, reference, cash_movement_id, created_by, created_at ) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?) - `).run(id, expenseId, payment.amount_minor, payment.method, businessDate, paymentId, reason, cashMovementId, input.actorUserId, now()); - return { status: 201, body: { expense: getExpense(db, expenseId), payment: getExpensePayment(db, expenseId, id) } }; - }, - )); + `).run( + id, + expenseId, + payment.amount_minor, + payment.method, + businessDate, + paymentId, + reason, + cashMovementId, + input.actorUserId, + now(), + ); + return { + status: 201, + body: { + expense: getExpense(db, expenseId), + payment: getExpensePayment(db, expenseId, id), + }, + }; + }, + ), + ); } // ── Context ────────────────────────────────────────────────────────────────── diff --git a/tests/expense-api.test.ts b/tests/expense-api.test.ts index b0d710350..63d9f4d3b 100644 --- a/tests/expense-api.test.ts +++ b/tests/expense-api.test.ts @@ -15,15 +15,23 @@ const os = require('os'); const path = require('path'); const testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'flo-expense-api-')); Module._load = function (request: string, parent: unknown, isMain: boolean) { - if (request === 'electron') return { app: { isPackaged: true, getPath: () => testDir, getVersion: () => 'test' } }; + if (request === 'electron') + return { app: { isPackaged: true, getPath: () => testDir, getVersion: () => 'test' } }; return originalLoad.apply(this, arguments as any); }; const request = require('supertest'); const jwt = require('jsonwebtoken'); const { - initTestDb, createApp, seedOwnerUser, seedManagerUser, - assertOrThrow, assertEqualOrThrow, getResults, resetCounters, now, + initTestDb, + createApp, + seedOwnerUser, + seedManagerUser, + assertOrThrow, + assertEqualOrThrow, + getResults, + resetCounters, + now, } = require('./helpers/test-setup'); const { getDatabase } = require('../main/db'); const { getJWTSecret } = require('../main/routes/auth'); @@ -32,11 +40,25 @@ const { expenseRoutes } = require('../main/routes/expenses'); function seedRoleUser(db: any, label: string, role: string): Record { const userId = `${label}-user`; db.prepare(`INSERT OR IGNORE INTO users (id, name, email, password, role, is_active, created_at, updated_at) - VALUES (?, ?, ?, 'unused-test-hash', ?, 1, ?, ?)`).run(userId, label, `${label}@test.local`, role, now(), now()); - return { Authorization: `Bearer ${jwt.sign({ userId, role }, getJWTSecret(), { expiresIn: '1h' })}` }; + VALUES (?, ?, ?, 'unused-test-hash', ?, 1, ?, ?)`).run( + userId, + label, + `${label}@test.local`, + role, + now(), + now(), + ); + return { + Authorization: `Bearer ${jwt.sign({ userId, role }, getJWTSecret(), { expiresIn: '1h' })}`, + }; } -function setOverride(db: any, userId: string, permissionId: string, effect: 'allow' | 'deny'): void { +function setOverride( + db: any, + userId: string, + permissionId: string, + effect: 'allow' | 'deny', +): void { db.prepare(`INSERT OR REPLACE INTO user_permission_overrides (user_id, permission_id, effect, updated_by, created_at, updated_at) VALUES (?, ?, ?, 'owner-test-001', ?, ?)`).run(userId, permissionId, effect, now(), now()); } @@ -73,18 +95,38 @@ async function main(): Promise { ]; for (const [method, url] of endpoints) { const response = await (request(app) as any)[method](url).send({}); - assertEqualOrThrow(response.status, 401, `${method.toUpperCase()} ${url} rejects an anonymous request`); + assertEqualOrThrow( + response.status, + 401, + `${method.toUpperCase()} ${url} rejects an anonymous request`, + ); } - for (const [label, user] of [['cashier', cashier], ['server', server], ['chef', chef]] as Array<[string, Record]>) { + for (const [label, user] of [ + ['cashier', cashier], + ['server', server], + ['chef', chef], + ] as Array<[string, Record]>) { const read = await request(app).get('/api/expenses').set(user); assertEqualOrThrow(read.status, 403, `${label} cannot list expenses by default`); expectErrorCode(read.body, 'permission_denied', `${label} denial names its code`); - const write = await request(app).post('/api/expenses').set(user).set('Idempotency-Key', `denied-${label}`).send({}); + const write = await request(app) + .post('/api/expenses') + .set(user) + .set('Idempotency-Key', `denied-${label}`) + .send({}); assertEqualOrThrow(write.status, 403, `${label} cannot create an expense by default`); } - assertEqualOrThrow((await request(app).get('/api/expenses').set(owner)).status, 200, 'an owner can list expenses'); - assertEqualOrThrow((await request(app).get('/api/expenses').set(manager)).status, 200, 'a manager can list expenses'); + assertEqualOrThrow( + (await request(app).get('/api/expenses').set(owner)).status, + 200, + 'an owner can list expenses', + ); + assertEqualOrThrow( + (await request(app).get('/api/expenses').set(manager)).status, + 200, + 'a manager can list expenses', + ); // ── Context is the only place the browser learns store currency and business date ── const context = await request(app).get('/api/expenses/context').set(owner); @@ -94,75 +136,214 @@ async function main(): Promise { 'business_date,cash_session_open,currency_code', 'context exposes only currency, business date, and drawer state', ); - assertEqualOrThrow(context.body.currency_code, 'INR', 'context reports the configured store currency'); - assertEqualOrThrow(context.body.cash_session_open, false, 'context reports no open drawer in this fixture'); + assertEqualOrThrow( + context.body.currency_code, + 'INR', + 'context reports the configured store currency', + ); + assertEqualOrThrow( + context.body.cash_session_open, + false, + 'context reports no open drawer in this fixture', + ); const today = context.body.business_date as string; // ── Category mutations: explicit values, idempotent, same key scope as expenses ── - const categoryCreate = await request(app).post('/api/expenses/categories').set(manager).set('Idempotency-Key', 'api-cat-rent').send({ name: 'Rent' }); + const categoryCreate = await request(app) + .post('/api/expenses/categories') + .set(manager) + .set('Idempotency-Key', 'api-cat-rent') + .send({ name: 'Rent' }); assertEqualOrThrow(categoryCreate.status, 201, 'a manager can create a category'); const rentCategoryId = categoryCreate.body.category.id; - const categoryReplay = await request(app).post('/api/expenses/categories').set(manager).set('Idempotency-Key', 'api-cat-rent').send({ name: 'Rent' }); - assertEqualOrThrow(categoryReplay.status, 201, 'a replayed category create keeps its original status'); - assertEqualOrThrow(categoryReplay.headers['idempotent-replay'], 'true', 'a replayed create is labelled'); - assertEqualOrThrow(categoryReplay.body.category.id, rentCategoryId, 'a replayed create returns the committed id'); - const categoryConflict = await request(app).post('/api/expenses/categories').set(manager).set('Idempotency-Key', 'api-cat-rent').send({ name: 'Rent renamed' }); - assertEqualOrThrow(categoryConflict.status, 409, 'a reused key with a different payload is a conflict'); + const categoryReplay = await request(app) + .post('/api/expenses/categories') + .set(manager) + .set('Idempotency-Key', 'api-cat-rent') + .send({ name: 'Rent' }); + assertEqualOrThrow( + categoryReplay.status, + 201, + 'a replayed category create keeps its original status', + ); + assertEqualOrThrow( + categoryReplay.headers['idempotent-replay'], + 'true', + 'a replayed create is labelled', + ); + assertEqualOrThrow( + categoryReplay.body.category.id, + rentCategoryId, + 'a replayed create returns the committed id', + ); + const categoryConflict = await request(app) + .post('/api/expenses/categories') + .set(manager) + .set('Idempotency-Key', 'api-cat-rent') + .send({ name: 'Rent renamed' }); + assertEqualOrThrow( + categoryConflict.status, + 409, + 'a reused key with a different payload is a conflict', + ); expectErrorCode(categoryConflict.body, 'idempotency_conflict', 'the conflict names its code'); - const categoryNoKey = await request(app).post('/api/expenses/categories').set(manager).send({ name: 'Supplies' }); - assertEqualOrThrow(categoryNoKey.status, 400, 'a mutation without an idempotency key is rejected'); + const categoryNoKey = await request(app) + .post('/api/expenses/categories') + .set(manager) + .send({ name: 'Supplies' }); + assertEqualOrThrow( + categoryNoKey.status, + 400, + 'a mutation without an idempotency key is rejected', + ); expectErrorCode(categoryNoKey.body, 'idempotency_key_required', 'the missing key names its code'); - const categoryDuplicate = await request(app).post('/api/expenses/categories').set(manager).set('Idempotency-Key', 'api-cat-rent-2').send({ name: ' rent ' }); - assertEqualOrThrow(categoryDuplicate.status, 409, 'category names are unique case-insensitively through the API'); - const categoryMissing = await request(app).patch('/api/expenses/categories/missing').set(manager).set('Idempotency-Key', 'api-cat-missing').send({ name: 'Ghost' }); + const categoryDuplicate = await request(app) + .post('/api/expenses/categories') + .set(manager) + .set('Idempotency-Key', 'api-cat-rent-2') + .send({ name: ' rent ' }); + assertEqualOrThrow( + categoryDuplicate.status, + 409, + 'category names are unique case-insensitively through the API', + ); + const categoryMissing = await request(app) + .patch('/api/expenses/categories/missing') + .set(manager) + .set('Idempotency-Key', 'api-cat-missing') + .send({ name: 'Ghost' }); assertEqualOrThrow(categoryMissing.status, 404, 'patching an unknown category is a 404'); - assertEqualOrThrow(categoryMissing.body.error, 'Expense category not found', 'the 404 body is human-readable'); - const categoryNoop = await request(app).patch(`/api/expenses/categories/${rentCategoryId}`).set(manager).set('Idempotency-Key', 'api-cat-noop').send({}); + assertEqualOrThrow( + categoryMissing.body.error, + 'Expense category not found', + 'the 404 body is human-readable', + ); + const categoryNoop = await request(app) + .patch(`/api/expenses/categories/${rentCategoryId}`) + .set(manager) + .set('Idempotency-Key', 'api-cat-noop') + .send({}); assertEqualOrThrow(categoryNoop.status, 400, 'a category patch must set an explicit value'); - const categoryDeactivate = await request(app).patch(`/api/expenses/categories/${rentCategoryId}`).set(manager).set('Idempotency-Key', 'api-cat-off').send({ is_active: false }); - assertEqualOrThrow(categoryDeactivate.status, 200, 'a category can be deactivated with an explicit value'); - assertEqualOrThrow(categoryDeactivate.body.category.is_active, 0, 'the deactivation is stored, not toggled'); + const categoryDeactivate = await request(app) + .patch(`/api/expenses/categories/${rentCategoryId}`) + .set(manager) + .set('Idempotency-Key', 'api-cat-off') + .send({ is_active: false }); + assertEqualOrThrow( + categoryDeactivate.status, + 200, + 'a category can be deactivated with an explicit value', + ); + assertEqualOrThrow( + categoryDeactivate.body.category.is_active, + 0, + 'the deactivation is stored, not toggled', + ); assertOrThrow( - !(await request(app).get('/api/expenses/categories').set(manager)).body.categories.some((row: any) => row.id === rentCategoryId), + !(await request(app).get('/api/expenses/categories').set(manager)).body.categories.some( + (row: any) => row.id === rentCategoryId, + ), 'the default category list hides deactivated categories', ); assertOrThrow( - (await request(app).get('/api/expenses/categories?include_inactive=true').set(manager)).body.categories.some((row: any) => row.id === rentCategoryId), + ( + await request(app).get('/api/expenses/categories?include_inactive=true').set(manager) + ).body.categories.some((row: any) => row.id === rentCategoryId), 'include_inactive keeps deactivated categories readable', ); - const activeCategory = await request(app).post('/api/expenses/categories').set(manager).set('Idempotency-Key', 'api-cat-utilities').send({ name: 'Utilities' }); + const activeCategory = await request(app) + .post('/api/expenses/categories') + .set(manager) + .set('Idempotency-Key', 'api-cat-utilities') + .send({ name: 'Utilities' }); assertEqualOrThrow(activeCategory.status, 201, 'a second category is created'); const utilitiesCategoryId = activeCategory.body.category.id; - const inactiveExpense = await request(app).post('/api/expenses').set(manager).set('Idempotency-Key', 'api-exp-inactive').send({ - category_id: rentCategoryId, description: 'Inactive category', amount_minor: 500, incurred_on: today, - }); - assertEqualOrThrow(inactiveExpense.status, 409, 'a deactivated category cannot receive a new expense'); - expectErrorCode(inactiveExpense.body, 'category_inactive', 'the inactive category names its code'); + const inactiveExpense = await request(app) + .post('/api/expenses') + .set(manager) + .set('Idempotency-Key', 'api-exp-inactive') + .send({ + category_id: rentCategoryId, + description: 'Inactive category', + amount_minor: 500, + incurred_on: today, + }); + assertEqualOrThrow( + inactiveExpense.status, + 409, + 'a deactivated category cannot receive a new expense', + ); + expectErrorCode( + inactiveExpense.body, + 'category_inactive', + 'the inactive category names its code', + ); // ── Expense creation validates, then replays ── const createPayload = { - category_id: utilitiesCategoryId, description: 'October utilities', amount_minor: 420000, incurred_on: today, payee: 'City Power', notes: 'meter 12', + category_id: utilitiesCategoryId, + description: 'October utilities', + amount_minor: 420000, + incurred_on: today, + payee: 'City Power', + notes: 'meter 12', }; - const created = await request(app).post('/api/expenses').set(manager).set('Idempotency-Key', 'api-exp-utilities').send(createPayload); + const created = await request(app) + .post('/api/expenses') + .set(manager) + .set('Idempotency-Key', 'api-exp-utilities') + .send(createPayload); assertEqualOrThrow(created.status, 201, 'a manager can create an expense'); assertEqualOrThrow(created.body.expense.paid_minor, 0, 'a new expense never starts paid'); assertEqualOrThrow(created.body.expense.due_minor, 420000, 'the full amount is due'); - assertEqualOrThrow(created.body.expense.currency_code, 'INR', 'the expense snapshots the store currency'); - assertEqualOrThrow(created.body.expense.created_by, 'mgr-test-001', 'the expense attributes the authenticated actor'); + assertEqualOrThrow( + created.body.expense.currency_code, + 'INR', + 'the expense snapshots the store currency', + ); + assertEqualOrThrow( + created.body.expense.created_by, + 'mgr-test-001', + 'the expense attributes the authenticated actor', + ); const expenseId = created.body.expense.id; - const createReplay = await request(app).post('/api/expenses').set(manager).set('Idempotency-Key', 'api-exp-utilities').send(createPayload); - assertEqualOrThrow(createReplay.status, 201, 'a replayed expense create keeps its original status'); - assertEqualOrThrow(createReplay.headers['idempotent-replay'], 'true', 'a replayed expense create is labelled'); - assertEqualOrThrow(createReplay.body.expense.id, expenseId, 'a replayed expense create returns the committed id'); + const createReplay = await request(app) + .post('/api/expenses') + .set(manager) + .set('Idempotency-Key', 'api-exp-utilities') + .send(createPayload); + assertEqualOrThrow( + createReplay.status, + 201, + 'a replayed expense create keeps its original status', + ); + assertEqualOrThrow( + createReplay.headers['idempotent-replay'], + 'true', + 'a replayed expense create is labelled', + ); + assertEqualOrThrow( + createReplay.body.expense.id, + expenseId, + 'a replayed expense create returns the committed id', + ); assertEqualOrThrow( Number(db.prepare('SELECT COUNT(*) AS count FROM expenses').get().count), 1, 'the replay wrote only one expense row', ); - const changedPayload = await request(app).post('/api/expenses').set(manager).set('Idempotency-Key', 'api-exp-utilities').send({ ...createPayload, amount_minor: 1 }); + const changedPayload = await request(app) + .post('/api/expenses') + .set(manager) + .set('Idempotency-Key', 'api-exp-utilities') + .send({ ...createPayload, amount_minor: 1 }); assertEqualOrThrow(changedPayload.status, 409, 'the same key with a changed amount conflicts'); - expectErrorCode(changedPayload.body, 'idempotency_conflict', 'the changed payload names its code'); + expectErrorCode( + changedPayload.body, + 'idempotency_conflict', + 'the changed payload names its code', + ); const malformed: Array<[Record, string]> = [ [{ ...createPayload, amount_minor: 0 }, 'a zero amount'], @@ -179,24 +360,46 @@ async function main(): Promise { [{ ...createPayload, replaces_expense_id: expenseId }, 'a create-time replace link'], ]; for (const [payload, label] of malformed) { - const response = await request(app).post('/api/expenses').set(manager).set('Idempotency-Key', `api-malformed-${label.replace(/\W+/g, '-')}`).send(payload); + const response = await request(app) + .post('/api/expenses') + .set(manager) + .set('Idempotency-Key', `api-malformed-${label.replace(/\W+/g, '-')}`) + .send(payload); assertEqualOrThrow(response.status, 400, `${label} is rejected with 400`); - assertOrThrow(typeof response.body.error === 'string' && response.body.error.length > 0, `${label} returns an error message`); + assertOrThrow( + typeof response.body.error === 'string' && response.body.error.length > 0, + `${label} returns an error message`, + ); } const future = new Date(`${today}T00:00:00Z`); future.setUTCDate(future.getUTCDate() + 1); - const futureResponse = await request(app).post('/api/expenses').set(manager).set('Idempotency-Key', 'api-future').send({ ...createPayload, incurred_on: future.toISOString().slice(0, 10) }); + const futureResponse = await request(app) + .post('/api/expenses') + .set(manager) + .set('Idempotency-Key', 'api-future') + .send({ ...createPayload, incurred_on: future.toISOString().slice(0, 10) }); assertEqualOrThrow(futureResponse.status, 400, 'a future incurred date is rejected'); - const unknownCategory = await request(app).post('/api/expenses').set(manager).set('Idempotency-Key', 'api-unknown-cat').send({ ...createPayload, category_id: 'missing-category' }); + const unknownCategory = await request(app) + .post('/api/expenses') + .set(manager) + .set('Idempotency-Key', 'api-unknown-cat') + .send({ ...createPayload, category_id: 'missing-category' }); assertEqualOrThrow(unknownCategory.status, 404, 'an unknown category is a 404'); // ── Read consistency: list, detail and summary describe the same rows ── const historical = new Date(`${today}T00:00:00Z`); historical.setUTCDate(historical.getUTCDate() - 10); const historicalDate = historical.toISOString().slice(0, 10); - const second = await request(app).post('/api/expenses').set(manager).set('Idempotency-Key', 'api-exp-historical').send({ - category_id: utilitiesCategoryId, description: 'Earlier utilities', amount_minor: 80000, incurred_on: historicalDate, - }); + const second = await request(app) + .post('/api/expenses') + .set(manager) + .set('Idempotency-Key', 'api-exp-historical') + .send({ + category_id: utilitiesCategoryId, + description: 'Earlier utilities', + amount_minor: 80000, + incurred_on: historicalDate, + }); assertEqualOrThrow(second.status, 201, 'an earlier business date is accepted'); const list = await request(app).get('/api/expenses').set(manager); @@ -208,9 +411,14 @@ async function main(): Promise { const bounded = await request(app).get('/api/expenses?limit=1').set(manager); assertEqualOrThrow(bounded.body.expenses.length, 1, 'limit bounds the list'); assertEqualOrThrow(typeof bounded.body.nextCursor, 'string', 'a truncated list exposes a cursor'); - const nextPage = await request(app).get(`/api/expenses?limit=1&cursor=${encodeURIComponent(bounded.body.nextCursor)}`).set(manager); + const nextPage = await request(app) + .get(`/api/expenses?limit=1&cursor=${encodeURIComponent(bounded.body.nextCursor)}`) + .set(manager); assertEqualOrThrow(nextPage.body.expenses.length, 1, 'the cursor continues the list'); - assertOrThrow(nextPage.body.expenses[0].id !== bounded.body.expenses[0].id, 'the next page returns a different expense'); + assertOrThrow( + nextPage.body.expenses[0].id !== bounded.body.expenses[0].id, + 'the next page returns a different expense', + ); const badLimit = await request(app).get('/api/expenses?limit=0').set(manager); assertEqualOrThrow(badLimit.status, 400, 'a non-positive limit is rejected'); const badCursor = await request(app).get('/api/expenses?cursor=nonsense').set(manager); @@ -220,102 +428,491 @@ async function main(): Promise { assertEqualOrThrow(detail.status, 200, 'the detail endpoint is readable'); assertEqualOrThrow(detail.body.expense.id, expenseId, 'the detail returns the requested expense'); assertEqualOrThrow(detail.body.payments.length, 0, 'a new expense has no payment history'); - assertEqualOrThrow((await request(app).get('/api/expenses/missing-expense').set(manager)).status, 404, 'an unknown expense id is a 404'); + assertEqualOrThrow( + (await request(app).get('/api/expenses/missing-expense').set(manager)).status, + 404, + 'an unknown expense id is a 404', + ); - const summary = await request(app).get(`/api/expenses/summary?category_id=${utilitiesCategoryId}`).set(manager); - assertEqualOrThrow(summary.status, 200, 'the summary endpoint is routed before the expense id route'); - assertEqualOrThrow(summary.body.basis, 'active_expenses_incurred_in_range_paid_to_date', 'the summary states its basis'); + const summary = await request(app) + .get(`/api/expenses/summary?category_id=${utilitiesCategoryId}`) + .set(manager); + assertEqualOrThrow( + summary.status, + 200, + 'the summary endpoint is routed before the expense id route', + ); + assertEqualOrThrow( + summary.body.basis, + 'active_expenses_incurred_in_range_paid_to_date', + 'the summary states its basis', + ); assertEqualOrThrow(summary.body.groups.length, 1, 'the summary groups by currency and category'); assertEqualOrThrow(summary.body.groups[0].expense_count, 2, 'the summary counts both expenses'); - assertEqualOrThrow(summary.body.groups[0].incurred_minor, 500000, 'the summary reconciles the listed amounts'); + assertEqualOrThrow( + summary.body.groups[0].incurred_minor, + 500000, + 'the summary reconciles the listed amounts', + ); assertEqualOrThrow(summary.body.groups[0].net_paid_minor, 0, 'nothing is paid yet'); - assertEqualOrThrow(summary.body.groups[0].due_minor, 500000, 'due equals incurred while nothing is paid'); + assertEqualOrThrow( + summary.body.groups[0].due_minor, + 500000, + 'due equals incurred while nothing is paid', + ); const totals = summary.body.totals.find((row: any) => row.currency_code === 'INR'); - assertEqualOrThrow(totals.incurred_minor, 500000, 'currency totals reconcile with the grouped amounts'); + assertEqualOrThrow( + totals.incurred_minor, + 500000, + 'currency totals reconcile with the grouped amounts', + ); // ── Void and replace through HTTP ── - const voidMissingReason = await request(app).post(`/api/expenses/${expenseId}/void`).set(manager).set('Idempotency-Key', 'api-void-no-reason').send({}); + const voidMissingReason = await request(app) + .post(`/api/expenses/${expenseId}/void`) + .set(manager) + .set('Idempotency-Key', 'api-void-no-reason') + .send({}); assertEqualOrThrow(voidMissingReason.status, 400, 'a void without a reason is rejected'); - const voided = await request(app).post(`/api/expenses/${expenseId}/void`).set(manager).set('Idempotency-Key', 'api-void-1').send({ reason: 'Billed twice' }); + const voided = await request(app) + .post(`/api/expenses/${expenseId}/void`) + .set(manager) + .set('Idempotency-Key', 'api-void-1') + .send({ reason: 'Billed twice' }); assertEqualOrThrow(voided.status, 200, 'a manager can void an expense'); assertEqualOrThrow(voided.body.expense.status, 'voided', 'the void is reflected in the row'); - assertEqualOrThrow(voided.body.expense.amount_minor, 420000, 'the void preserves the original amount'); - const voidReplay = await request(app).post(`/api/expenses/${expenseId}/void`).set(manager).set('Idempotency-Key', 'api-void-1').send({ reason: 'Billed twice' }); + assertEqualOrThrow( + voided.body.expense.amount_minor, + 420000, + 'the void preserves the original amount', + ); + const voidReplay = await request(app) + .post(`/api/expenses/${expenseId}/void`) + .set(manager) + .set('Idempotency-Key', 'api-void-1') + .send({ reason: 'Billed twice' }); assertEqualOrThrow(voidReplay.status, 200, 'a replayed void answers 200'); - assertEqualOrThrow(voidReplay.headers['idempotent-replay'], 'true', 'a replayed void is labelled'); - const doubleVoid = await request(app).post(`/api/expenses/${expenseId}/void`).set(manager).set('Idempotency-Key', 'api-void-2').send({ reason: 'Again' }); + assertEqualOrThrow( + voidReplay.headers['idempotent-replay'], + 'true', + 'a replayed void is labelled', + ); + const doubleVoid = await request(app) + .post(`/api/expenses/${expenseId}/void`) + .set(manager) + .set('Idempotency-Key', 'api-void-2') + .send({ reason: 'Again' }); assertEqualOrThrow(doubleVoid.status, 409, 'an already voided expense cannot be voided again'); - const afterVoidSummary = await request(app).get(`/api/expenses/summary?category_id=${utilitiesCategoryId}`).set(manager); - assertEqualOrThrow(afterVoidSummary.body.groups[0].expense_count, 1, 'headline totals exclude the voided expense'); - assertEqualOrThrow(afterVoidSummary.body.groups[0].incurred_minor, 80000, 'headline incurred drops the voided amount'); - const voidHistory = await request(app).get(`/api/expenses?status=voided&category_id=${utilitiesCategoryId}`).set(manager); - assertEqualOrThrow(voidHistory.body.expenses.length, 1, 'the history filter still reaches the voided expense'); - assertEqualOrThrow(voidHistory.body.expenses[0].id, expenseId, 'the history filter returns the voided row'); + const afterVoidSummary = await request(app) + .get(`/api/expenses/summary?category_id=${utilitiesCategoryId}`) + .set(manager); + assertEqualOrThrow( + afterVoidSummary.body.groups[0].expense_count, + 1, + 'headline totals exclude the voided expense', + ); + assertEqualOrThrow( + afterVoidSummary.body.groups[0].incurred_minor, + 80000, + 'headline incurred drops the voided amount', + ); + const voidHistory = await request(app) + .get(`/api/expenses?status=voided&category_id=${utilitiesCategoryId}`) + .set(manager); + assertEqualOrThrow( + voidHistory.body.expenses.length, + 1, + 'the history filter still reaches the voided expense', + ); + assertEqualOrThrow( + voidHistory.body.expenses[0].id, + expenseId, + 'the history filter returns the voided row', + ); const inactiveStatus = await request(app).get('/api/expenses?status=paid').set(manager); assertEqualOrThrow(inactiveStatus.status, 400, 'an unknown status filter is rejected'); - const replacePayload = { category_id: utilitiesCategoryId, description: 'Corrected utilities', amount_minor: 90000, incurred_on: historicalDate, reason: 'Tighter reading' }; - const replaced = await request(app).post(`/api/expenses/${second.body.expense.id}/replace`).set(manager).set('Idempotency-Key', 'api-replace-1').send(replacePayload); + const replacePayload = { + category_id: utilitiesCategoryId, + description: 'Corrected utilities', + amount_minor: 90000, + incurred_on: historicalDate, + reason: 'Tighter reading', + }; + const replaced = await request(app) + .post(`/api/expenses/${second.body.expense.id}/replace`) + .set(manager) + .set('Idempotency-Key', 'api-replace-1') + .send(replacePayload); assertEqualOrThrow(replaced.status, 201, 'replace returns the replacement'); - assertEqualOrThrow(replaced.body.replaced_expense_id, second.body.expense.id, 'replace names the source'); - assertEqualOrThrow(replaced.body.expense.replaces_expense_id, second.body.expense.id, 'the replacement links back'); - const sourceAfterReplace = await request(app).get(`/api/expenses/${second.body.expense.id}`).set(manager); - assertEqualOrThrow(sourceAfterReplace.body.expense.status, 'replaced', 'the source is reported as replaced'); - assertEqualOrThrow(sourceAfterReplace.body.expense.void_reason, 'Tighter reading', 'the source records the replacement reason'); - const replaceAgain = await request(app).post(`/api/expenses/${second.body.expense.id}/replace`).set(manager).set('Idempotency-Key', 'api-replace-2').send(replacePayload); + assertEqualOrThrow( + replaced.body.replaced_expense_id, + second.body.expense.id, + 'replace names the source', + ); + assertEqualOrThrow( + replaced.body.expense.replaces_expense_id, + second.body.expense.id, + 'the replacement links back', + ); + const sourceAfterReplace = await request(app) + .get(`/api/expenses/${second.body.expense.id}`) + .set(manager); + assertEqualOrThrow( + sourceAfterReplace.body.expense.status, + 'replaced', + 'the source is reported as replaced', + ); + assertEqualOrThrow( + sourceAfterReplace.body.expense.void_reason, + 'Tighter reading', + 'the source records the replacement reason', + ); + const replaceAgain = await request(app) + .post(`/api/expenses/${second.body.expense.id}/replace`) + .set(manager) + .set('Idempotency-Key', 'api-replace-2') + .send(replacePayload); assertEqualOrThrow(replaceAgain.status, 409, 'a replaced source cannot be replaced twice'); // ── A paid expense is protected from void and replacement ── - const paid = await request(app).post('/api/expenses').set(manager).set('Idempotency-Key', 'api-exp-paid').send({ - category_id: utilitiesCategoryId, description: 'Partially settled', amount_minor: 100000, incurred_on: today, - }); + const paid = await request(app) + .post('/api/expenses') + .set(manager) + .set('Idempotency-Key', 'api-exp-paid') + .send({ + category_id: utilitiesCategoryId, + description: 'Partially settled', + amount_minor: 100000, + incurred_on: today, + }); db.prepare(`INSERT INTO expense_payments (id, expense_id, amount_minor, method, business_date, created_by, created_at) - VALUES ('api-seed-payment', ?, 25000, 'cash', ?, 'mgr-test-001', ?)`).run(paid.body.expense.id, today, `${today} 12:00:00`); + VALUES ('api-seed-payment', ?, 25000, 'cash', ?, 'mgr-test-001', ?)`).run( + paid.body.expense.id, + today, + `${today} 12:00:00`, + ); const paidDetail = await request(app).get(`/api/expenses/${paid.body.expense.id}`).set(manager); - assertEqualOrThrow(paidDetail.body.expense.paid_minor, 25000, 'the detail reports net paid from the ledger'); + assertEqualOrThrow( + paidDetail.body.expense.paid_minor, + 25000, + 'the detail reports net paid from the ledger', + ); assertEqualOrThrow(paidDetail.body.payments.length, 1, 'the detail returns the payment history'); - assertEqualOrThrow(paidDetail.body.expense.due_minor, 75000, 'the detail reports due against net paid'); - const paidVoid = await request(app).post(`/api/expenses/${paid.body.expense.id}/void`).set(manager).set('Idempotency-Key', 'api-void-paid').send({ reason: 'Try' }); + assertEqualOrThrow( + paidDetail.body.expense.due_minor, + 75000, + 'the detail reports due against net paid', + ); + const paidVoid = await request(app) + .post(`/api/expenses/${paid.body.expense.id}/void`) + .set(manager) + .set('Idempotency-Key', 'api-void-paid') + .send({ reason: 'Try' }); assertEqualOrThrow(paidVoid.status, 409, 'a paid expense cannot be voided'); expectErrorCode(paidVoid.body, 'expense_has_payments', 'the paid guard names its code'); - const paidSummary = await request(app).get(`/api/expenses/summary?category_id=${utilitiesCategoryId}`).set(manager); + const paidSummary = await request(app) + .get(`/api/expenses/summary?category_id=${utilitiesCategoryId}`) + .set(manager); const paidGroup = paidSummary.body.groups[0]; assertEqualOrThrow(paidGroup.net_paid_minor, 25000, 'the summary reflects the recorded payment'); - assertEqualOrThrow(paidGroup.incurred_minor - paidGroup.net_paid_minor, paidGroup.due_minor, 'the summary reconciles incurred = paid + due'); - const paidReplace = await request(app).post(`/api/expenses/${paid.body.expense.id}/replace`).set(manager).set('Idempotency-Key', 'api-replace-paid').send({ ...replacePayload, category_id: utilitiesCategoryId }); + assertEqualOrThrow( + paidGroup.incurred_minor - paidGroup.net_paid_minor, + paidGroup.due_minor, + 'the summary reconciles incurred = paid + due', + ); + const paidReplace = await request(app) + .post(`/api/expenses/${paid.body.expense.id}/replace`) + .set(manager) + .set('Idempotency-Key', 'api-replace-paid') + .send({ ...replacePayload, category_id: utilitiesCategoryId }); assertEqualOrThrow(paidReplace.status, 409, 'a paid expense cannot be replaced'); // ── Live grants and revocation are evaluated per request ── const cashierId = 'expense-cashier-user'; - const deniedWrite = await request(app).post('/api/expenses').set(cashier).set('Idempotency-Key', 'api-cashier-1').send({ - category_id: utilitiesCategoryId, description: 'Cashier attempt', amount_minor: 1000, incurred_on: today, - }); + const deniedWrite = await request(app) + .post('/api/expenses') + .set(cashier) + .set('Idempotency-Key', 'api-cashier-1') + .send({ + category_id: utilitiesCategoryId, + description: 'Cashier attempt', + amount_minor: 1000, + incurred_on: today, + }); assertEqualOrThrow(deniedWrite.status, 403, 'a cashier cannot write expenses before a grant'); setOverride(db, cashierId, 'expenses.view', 'allow'); - assertEqualOrThrow((await request(app).get('/api/expenses').set(cashier)).status, 200, 'a granted view reaches the live read path'); - const viewOnlyWrite = await request(app).post('/api/expenses').set(cashier).set('Idempotency-Key', 'api-cashier-2').send({ - category_id: utilitiesCategoryId, description: 'View only', amount_minor: 1000, incurred_on: today, - }); + assertEqualOrThrow( + (await request(app).get('/api/expenses').set(cashier)).status, + 200, + 'a granted view reaches the live read path', + ); + const viewOnlyWrite = await request(app) + .post('/api/expenses') + .set(cashier) + .set('Idempotency-Key', 'api-cashier-2') + .send({ + category_id: utilitiesCategoryId, + description: 'View only', + amount_minor: 1000, + incurred_on: today, + }); assertEqualOrThrow(viewOnlyWrite.status, 403, 'view alone cannot write an expense'); setOverride(db, cashierId, 'expenses.manage', 'allow'); - const grantedCreate = await request(app).post('/api/expenses').set(cashier).set('Idempotency-Key', 'api-cashier-3').send({ - category_id: utilitiesCategoryId, description: 'Granted cashier expense', amount_minor: 1000, incurred_on: today, - }); + const grantedCreate = await request(app) + .post('/api/expenses') + .set(cashier) + .set('Idempotency-Key', 'api-cashier-3') + .send({ + category_id: utilitiesCategoryId, + description: 'Granted cashier expense', + amount_minor: 1000, + incurred_on: today, + }); assertEqualOrThrow(grantedCreate.status, 201, 'a granted cashier can create an expense'); - assertEqualOrThrow(grantedCreate.body.expense.created_by, cashierId, 'the granted write attributes the cashier'); + assertEqualOrThrow( + grantedCreate.body.expense.created_by, + cashierId, + 'the granted write attributes the cashier', + ); setOverride(db, cashierId, 'expenses.view', 'deny'); - const deniedAfterRevoke = await request(app).post('/api/expenses').set(cashier).set('Idempotency-Key', 'api-cashier-4').send({ - category_id: utilitiesCategoryId, description: 'After revocation', amount_minor: 1000, incurred_on: today, - }); - assertEqualOrThrow(deniedAfterRevoke.status, 403, 'revoking view also blocks the write path that needs it'); + const deniedAfterRevoke = await request(app) + .post('/api/expenses') + .set(cashier) + .set('Idempotency-Key', 'api-cashier-4') + .send({ + category_id: utilitiesCategoryId, + description: 'After revocation', + amount_minor: 1000, + incurred_on: today, + }); + assertEqualOrThrow( + deniedAfterRevoke.status, + 403, + 'revoking view also blocks the write path that needs it', + ); db.prepare('DELETE FROM user_permission_overrides WHERE user_id = ?').run(cashierId); - assertEqualOrThrow((await request(app).get('/api/expenses').set(cashier)).status, 403, 'clearing the grant restores the shipped default'); + assertEqualOrThrow( + (await request(app).get('/api/expenses').set(cashier)).status, + 403, + 'clearing the grant restores the shipped default', + ); assertOrThrow( !db.prepare('SELECT 1 FROM expenses WHERE description = ?').get('After revocation'), 'a denied write leaves no expense row behind', ); + // ── Aggregate safe-integer boundary, 422 range guard, and HTTP recovery ── + const currentActiveInr = Number( + ( + db + .prepare( + `SELECT COALESCE(SUM(amount_minor), 0) AS total FROM expenses WHERE voided_at IS NULL AND currency_code = 'INR'`, + ) + .get() as { total: number } + ).total, + ); + const remainingToMax = Number.MAX_SAFE_INTEGER - currentActiveInr; + const maxBoundaryCreate = await request(app) + .post('/api/expenses') + .set(manager) + .set('Idempotency-Key', 'api-exp-max-boundary') + .send({ + category_id: utilitiesCategoryId, + description: 'Reach exact max safe integer', + amount_minor: remainingToMax, + incurred_on: today, + }); + assertEqualOrThrow( + maxBoundaryCreate.status, + 201, + 'HTTP create reaching exact Number.MAX_SAFE_INTEGER succeeds with 201', + ); + const maxBoundaryId = maxBoundaryCreate.body.expense.id as string; + + const maxBoundaryReplay = await request(app) + .post('/api/expenses') + .set(manager) + .set('Idempotency-Key', 'api-exp-max-boundary') + .send({ + category_id: utilitiesCategoryId, + description: 'Reach exact max safe integer', + amount_minor: remainingToMax, + incurred_on: today, + }); + assertEqualOrThrow( + maxBoundaryReplay.status, + 201, + 'HTTP replay at Number.MAX_SAFE_INTEGER returns 201', + ); + assertEqualOrThrow( + maxBoundaryReplay.headers['idempotent-replay'], + 'true', + 'HTTP replay at Number.MAX_SAFE_INTEGER sets Idempotent-Replay', + ); + assertEqualOrThrow( + maxBoundaryReplay.body.expense.id, + maxBoundaryId, + 'HTTP replay at Number.MAX_SAFE_INTEGER returns original id', + ); + + const maxBoundarySummary = await request(app).get('/api/expenses/summary').set(manager); + assertEqualOrThrow( + maxBoundarySummary.status, + 200, + 'HTTP summary at exact Number.MAX_SAFE_INTEGER returns 200', + ); + const inrBoundaryTotal = maxBoundarySummary.body.totals.find( + (row: any) => row.currency_code === 'INR', + ); + assertEqualOrThrow( + inrBoundaryTotal.incurred_minor, + Number.MAX_SAFE_INTEGER, + 'HTTP summary reports exact Number.MAX_SAFE_INTEGER incurred', + ); + assertEqualOrThrow( + inrBoundaryTotal.net_paid_minor, + 25000, + 'HTTP summary preserves existing payment total at max-safe boundary', + ); + assertEqualOrThrow( + inrBoundaryTotal.due_minor, + Number.MAX_SAFE_INTEGER - 25000, + 'HTTP summary reconciles due at max-safe boundary', + ); + + const overBy100 = await request(app) + .post('/api/expenses') + .set(manager) + .set('Idempotency-Key', 'api-exp-over-100') + .send({ + category_id: utilitiesCategoryId, + description: 'Exceed max safe integer by 100', + amount_minor: 100, + incurred_on: today, + }); + assertEqualOrThrow( + overBy100.status, + 422, + 'HTTP create adding 100 past Number.MAX_SAFE_INTEGER is rejected with 422', + ); + expectErrorCode( + overBy100.body, + 'amount_range_exceeded', + 'HTTP 422 create rejection carries amount_range_exceeded code', + ); + assertOrThrow( + !db + .prepare('SELECT 1 FROM expenses WHERE description = ?') + .get('Exceed max safe integer by 100'), + 'HTTP 422 create rejection commits no expense row', + ); + + // Legacy oversized row in database: summary returns 422 (not 500), while list/detail/payments/void/replace remain usable + const legacyHttpId = 'exp_legacy_http_oversized_100'; + db.prepare(` + INSERT INTO expenses ( + id, category_id, category_name, description, amount_minor, currency_code, incurred_on, created_by, created_at + ) VALUES (?, ?, 'Utilities', 'Legacy oversized HTTP fixture', 100, 'INR', ?, 'mgr-test-001', ?) + `).run(legacyHttpId, utilitiesCategoryId, today, now()); + + const oversizedSummary = await request(app).get('/api/expenses/summary').set(manager); + assertEqualOrThrow( + oversizedSummary.status, + 422, + 'HTTP summary on legacy oversized data returns 422 instead of 500', + ); + expectErrorCode( + oversizedSummary.body, + 'amount_range_exceeded', + 'HTTP 422 summary carries amount_range_exceeded code', + ); + + const listDuringOversized = await request(app).get('/api/expenses').set(manager); + assertEqualOrThrow( + listDuringOversized.status, + 200, + 'HTTP list remains usable while summary is out of range', + ); + const detailDuringOversized = await request(app) + .get(`/api/expenses/${paid.body.expense.id}`) + .set(manager); + assertEqualOrThrow( + detailDuringOversized.status, + 200, + 'HTTP detail remains usable while summary is out of range', + ); + assertEqualOrThrow( + detailDuringOversized.body.expense.paid_minor, + 25000, + 'payment ledger remains intact while summary is out of range', + ); + assertEqualOrThrow( + detailDuringOversized.body.payments.length, + 1, + 'payment history remains readable while summary is out of range', + ); + const replacedDetailDuringOversized = await request(app) + .get(`/api/expenses/${second.body.expense.id}`) + .set(manager); + assertEqualOrThrow( + replacedDetailDuringOversized.status, + 200, + 'replaced expense record remains readable while summary is out of range', + ); + assertEqualOrThrow( + replacedDetailDuringOversized.body.expense.status, + 'replaced', + 'replacement status remains intact', + ); + + // Recover via replace (subtracting replaced active amount) + const recoverByReplaceHttp = await request(app) + .post(`/api/expenses/${maxBoundaryId}/replace`) + .set(manager) + .set('Idempotency-Key', 'api-replace-recover-boundary') + .send({ + category_id: utilitiesCategoryId, + description: 'Adjusted boundary expense', + amount_minor: remainingToMax - 100, + incurred_on: today, + reason: 'Bring total back into supported range', + }); + assertEqualOrThrow( + recoverByReplaceHttp.status, + 201, + 'HTTP replace recovery succeeds while summary was out of range', + ); + const summaryAfterReplaceRecovery = await request(app).get('/api/expenses/summary').set(manager); + assertEqualOrThrow( + summaryAfterReplaceRecovery.status, + 200, + 'HTTP summary returns 200 after replace recovery', + ); + assertEqualOrThrow( + summaryAfterReplaceRecovery.body.totals.find((row: any) => row.currency_code === 'INR') + .incurred_minor, + Number.MAX_SAFE_INTEGER, + 'HTTP summary reports exact Number.MAX_SAFE_INTEGER after replace recovery', + ); + + // Void the legacy 100 row and verify summary updates cleanly + const voidLegacyHttp = await request(app) + .post(`/api/expenses/${legacyHttpId}/void`) + .set(manager) + .set('Idempotency-Key', 'api-void-legacy-100') + .send({ reason: 'Remove legacy duplicate' }); + assertEqualOrThrow(voidLegacyHttp.status, 200, 'HTTP void of legacy row succeeds'); + const summaryAfterVoidRecovery = await request(app).get('/api/expenses/summary').set(manager); + assertEqualOrThrow(summaryAfterVoidRecovery.status, 200, 'HTTP summary returns 200 after void'); + assertEqualOrThrow( + summaryAfterVoidRecovery.body.totals.find((row: any) => row.currency_code === 'INR') + .incurred_minor, + Number.MAX_SAFE_INTEGER - 100, + 'HTTP summary reflects voided legacy row', + ); + const results = getResults(); console.log(`\nExpense API: ${results.passed}/${results.total} checks passed`); if (results.failed > 0) { @@ -325,19 +922,23 @@ async function main(): Promise { return results.failed; } -main().then((failures: number) => { - const { closeDatabase } = require('../main/db'); - closeDatabase(); - Module._load = originalLoad; - fs.rmSync(testDir, { recursive: true, force: true }); - console.log(failures > 0 ? '❌ Expense API test finished with failures' : '✅ Expense API test passed'); -}).catch((error: unknown) => { - try { +main() + .then((failures: number) => { const { closeDatabase } = require('../main/db'); closeDatabase(); - } catch { } - Module._load = originalLoad; - fs.rmSync(testDir, { recursive: true, force: true }); - console.error(error); - process.exit(1); -}); + Module._load = originalLoad; + fs.rmSync(testDir, { recursive: true, force: true }); + console.log( + failures > 0 ? '❌ Expense API test finished with failures' : '✅ Expense API test passed', + ); + }) + .catch((error: unknown) => { + try { + const { closeDatabase } = require('../main/db'); + closeDatabase(); + } catch {} + Module._load = originalLoad; + fs.rmSync(testDir, { recursive: true, force: true }); + console.error(error); + process.exit(1); + }); diff --git a/tests/expense-records.test.ts b/tests/expense-records.test.ts index 62bf5dffc..34aa25409 100644 --- a/tests/expense-records.test.ts +++ b/tests/expense-records.test.ts @@ -16,32 +16,49 @@ const os = require('os'); const path = require('path'); const testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'flo-expense-records-')); Module._load = function (request: string, parent: unknown, isMain: boolean) { - if (request === 'electron') return { app: { isPackaged: true, getPath: () => testDir, getVersion: () => 'test' } }; + if (request === 'electron') + return { app: { isPackaged: true, getPath: () => testDir, getVersion: () => 'test' } }; return originalLoad.apply(this, arguments as any); }; const { - initDatabase, getDatabase, closeDatabase, getCurrentSchemaVersion, - buildIdealSchemaDb, MIGRATIONS, now, + initDatabase, + getDatabase, + closeDatabase, + getCurrentSchemaVersion, + buildIdealSchemaDb, + MIGRATIONS, + now, } = require('../main/db'); const { - assertOrThrow, assertEqualOrThrow, assertIncludesOrThrow, assertGreaterThanOrThrow, getResults, resetCounters, + assertOrThrow, + assertEqualOrThrow, + assertIncludesOrThrow, + assertGreaterThanOrThrow, + getResults, + resetCounters, } = require('./helpers/test-setup'); const EXPENSE_TABLES = ['expense_categories', 'expenses', 'expense_payments', 'expense_mutations']; function tableNames(db: any): string[] { - return (db.prepare(`SELECT name FROM sqlite_master WHERE type = 'table'`).all() as { name: string }[]) - .map((row) => row.name); + return ( + db.prepare(`SELECT name FROM sqlite_master WHERE type = 'table'`).all() as { name: string }[] + ).map((row) => row.name); } function indexNames(db: any, table: string): string[] { - return (db.prepare(`SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = ?`).all(table) as { name: string }[]) - .map((row) => row.name); + return ( + db + .prepare(`SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = ?`) + .all(table) as { name: string }[] + ).map((row) => row.name); } function columns(db: any, table: string): string[] { - return (db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[]).map((row) => row.name); + return (db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[]).map( + (row) => row.name, + ); } function throwsSqlite(fn: () => void): boolean { @@ -56,14 +73,18 @@ function throwsSqlite(fn: () => void): boolean { /** Deep-equal row snapshots; JSON of ordered selects is enough for row fidelity. */ function snapshot(db: any, selects: Record): Record { const out: Record = {}; - for (const [label, sql] of Object.entries(selects)) out[label] = JSON.stringify(db.prepare(sql).all()); + for (const [label, sql] of Object.entries(selects)) + out[label] = JSON.stringify(db.prepare(sql).all()); return out; } function backupFiles(): string[] { const dir = path.join(testDir, 'backups'); if (!fs.existsSync(dir)) return []; - return fs.readdirSync(dir).filter((name) => name.endsWith('.db')).sort(); + return fs + .readdirSync(dir) + .filter((name) => name.endsWith('.db')) + .sort(); } const PRISTINE_MIGRATIONS = MIGRATIONS.slice(); @@ -74,14 +95,22 @@ function seedPopulatedPriorDatabase(db: any): void { const ts = now(); db.prepare(`INSERT INTO users (id, name, email, password, role, is_active, created_at, updated_at) VALUES ('exp-owner', 'Owner', 'exp-owner@test.local', 'x', 'owner', 1, ?, ?)`).run(ts, ts); - db.prepare(`INSERT INTO categories (id, name, sort_order) VALUES ('exp-cat', 'Expense Fixture Category', 1)`).run(); + db.prepare( + `INSERT INTO categories (id, name, sort_order) VALUES ('exp-cat', 'Expense Fixture Category', 1)`, + ).run(); db.prepare(`INSERT INTO products (id, name, price, category_id, is_active, created_at, updated_at) VALUES ('exp-product', 'Fixture Coffee', 60, 'exp-cat', 1, ?, ?)`).run(ts, ts); db.prepare(`INSERT INTO orders (order_number, type, status, subtotal, tax_amount, total, created_at, updated_at) VALUES ('EXP-ORD-1', 'dine_in', 'completed', 60, 0, 60, ?, ?)`).run(ts, ts); - const orderId = Number(db.prepare(`SELECT id FROM orders WHERE order_number = 'EXP-ORD-1'`).get().id); + const orderId = Number( + db.prepare(`SELECT id FROM orders WHERE order_number = 'EXP-ORD-1'`).get().id, + ); db.prepare(`INSERT INTO order_items (order_id, product_id, product_name, quantity, unit_price, subtotal, total, status, created_at, updated_at) - VALUES (?, 'exp-product', 'Fixture Coffee', 1, 60, 60, 60, 'served', ?, ?)`).run(orderId, ts, ts); + VALUES (?, 'exp-product', 'Fixture Coffee', 1, 60, 60, 60, 'served', ?, ?)`).run( + orderId, + ts, + ts, + ); db.prepare(`INSERT INTO bills (bill_number, order_id, subtotal, tax_amount, total, payment_status, paid_amount, balance, created_at, updated_at) VALUES ('EXP-INV-1', ?, 60, 0, 60, 'paid', 60, 0, ?, ?)`).run(orderId, ts, ts); db.prepare(`INSERT INTO supplies (id, name, base_unit, stock_quantity, low_stock_threshold, is_active, created_at, updated_at) @@ -102,16 +131,28 @@ function main(): void { // The literal is the version this migration was registered with, which never // changes; the fixture below stays dynamic, so a later migration does not // stale this suite. - const expenseMigration = PRISTINE_MIGRATIONS.find((migration: any) => migration.name === 'add_expense_records'); + const expenseMigration = PRISTINE_MIGRATIONS.find( + (migration: any) => migration.name === 'add_expense_records', + ); assertOrThrow(expenseMigration, 'the expense records migration is registered'); - assertEqualOrThrow(expenseMigration.version, 108, 'the expense records migration keeps its registered version'); + assertEqualOrThrow( + expenseMigration.version, + 108, + 'the expense records migration keeps its registered version', + ); // ── Upgrade: a populated v107 database reaches v108 without losing rows ── MIGRATIONS.length = 0; - MIGRATIONS.push(...PRISTINE_MIGRATIONS.filter((migration: any) => migration.version <= PRIOR_VERSION)); + MIGRATIONS.push( + ...PRISTINE_MIGRATIONS.filter((migration: any) => migration.version <= PRIOR_VERSION), + ); initDatabase(); let db = getDatabase(); - assertEqualOrThrow(getCurrentSchemaVersion(), PRIOR_VERSION, 'fixture database starts one version behind'); + assertEqualOrThrow( + getCurrentSchemaVersion(), + PRIOR_VERSION, + 'fixture database starts one version behind', + ); seedPopulatedPriorDatabase(db); const preservedSelections = { @@ -132,7 +173,11 @@ function main(): void { MIGRATIONS.push(...PRISTINE_MIGRATIONS); initDatabase(); db = getDatabase(); - assertEqualOrThrow(getCurrentSchemaVersion(), LATEST_VERSION, 'populated prior database upgrades to the registry tail'); + assertEqualOrThrow( + getCurrentSchemaVersion(), + LATEST_VERSION, + 'populated prior database upgrades to the registry tail', + ); const after = snapshot(db, preservedSelections); for (const label of Object.keys(preservedSelections)) { @@ -149,7 +194,11 @@ function main(): void { } const backupNames = backupFiles(); - assertGreaterThanOrThrow(backupNames.length, backupsBefore, 'the upgrade writes a pre-migration backup'); + assertGreaterThanOrThrow( + backupNames.length, + backupsBefore, + 'the upgrade writes a pre-migration backup', + ); assertOrThrow( backupNames.some((name) => name.includes(`pre-v${PRIOR_VERSION}-to-v${LATEST_VERSION}`)), 'the pre-migration backup names the exact version jump', @@ -158,7 +207,11 @@ function main(): void { const FixtureDatabase = require('better-sqlite3'); const backupDb = new FixtureDatabase(backupPath, { readonly: true }); try { - assertEqualOrThrow(backupDb.pragma('user_version', { simple: true }), PRIOR_VERSION, 'the backup snapshot records the pre-migration version'); + assertEqualOrThrow( + backupDb.pragma('user_version', { simple: true }), + PRIOR_VERSION, + 'the backup snapshot records the pre-migration version', + ); assertEqualOrThrow( (backupDb.prepare(`SELECT COUNT(*) AS count FROM orders`).get() as { count: number }).count, 1, @@ -167,16 +220,32 @@ function main(): void { } finally { backupDb.close(); } - assertEqualOrThrow(db.pragma('foreign_keys', { simple: true }), 1, 'foreign keys are enforced after migrations'); - assertEqualOrThrow((db.prepare('PRAGMA foreign_key_check').all() as unknown[]).length, 0, 'the upgraded database has no foreign-key violations'); + assertEqualOrThrow( + db.pragma('foreign_keys', { simple: true }), + 1, + 'foreign keys are enforced after migrations', + ); + assertEqualOrThrow( + (db.prepare('PRAGMA foreign_key_check').all() as unknown[]).length, + 0, + 'the upgraded database has no foreign-key violations', + ); // ── Repeated initialization: idempotent, no extra migration or backup ── const backupsAfterUpgrade = backupFiles().length; closeDatabase(); initDatabase(); db = getDatabase(); - assertEqualOrThrow(getCurrentSchemaVersion(), LATEST_VERSION, 're-opening a current database stays on the registry tail'); - assertEqualOrThrow(backupFiles().length, backupsAfterUpgrade, 'a second open of a current database writes no extra backup'); + assertEqualOrThrow( + getCurrentSchemaVersion(), + LATEST_VERSION, + 're-opening a current database stays on the registry tail', + ); + assertEqualOrThrow( + backupFiles().length, + backupsAfterUpgrade, + 'a second open of a current database writes no extra backup', + ); for (const table of EXPENSE_TABLES) { assertOrThrow(tableNames(db).includes(table), `${table} survives repeated initialization`); } @@ -184,7 +253,11 @@ function main(): void { // ── Ideal schema tracks the same registry ── const idealDb = buildIdealSchemaDb(); try { - assertEqualOrThrow(idealDb.pragma('user_version', { simple: true }), LATEST_VERSION, 'the ideal schema reaches the same version'); + assertEqualOrThrow( + idealDb.pragma('user_version', { simple: true }), + LATEST_VERSION, + 'the ideal schema reaches the same version', + ); for (const table of EXPENSE_TABLES) { assertOrThrow(tableNames(idealDb).includes(table), `${table} exists in the ideal schema`); } @@ -194,87 +267,226 @@ function main(): void { // ── Columns and indexes that later expense reads and writes depend on ── const expectedColumns: Record = { - expense_categories: ['id', 'name', 'name_key', 'is_active', 'created_by', 'updated_by', 'created_at', 'updated_at'], + expense_categories: [ + 'id', + 'name', + 'name_key', + 'is_active', + 'created_by', + 'updated_by', + 'created_at', + 'updated_at', + ], expenses: [ - 'id', 'category_id', 'category_name', 'description', 'payee', 'notes', 'amount_minor', - 'currency_code', 'incurred_on', 'created_by', 'created_at', 'replaces_expense_id', - 'voided_at', 'voided_by', 'void_reason', + 'id', + 'category_id', + 'category_name', + 'description', + 'payee', + 'notes', + 'amount_minor', + 'currency_code', + 'incurred_on', + 'created_by', + 'created_at', + 'replaces_expense_id', + 'voided_at', + 'voided_by', + 'void_reason', ], expense_payments: [ - 'id', 'expense_id', 'amount_minor', 'method', 'business_date', 'reversal_of', 'reason', - 'reference', 'cash_movement_id', 'created_by', 'created_at', + 'id', + 'expense_id', + 'amount_minor', + 'method', + 'business_date', + 'reversal_of', + 'reason', + 'reference', + 'cash_movement_id', + 'created_by', + 'created_at', + ], + expense_mutations: [ + 'actor_user_id', + 'idempotency_key', + 'operation', + 'resource_id', + 'request_hash', + 'response_json', + 'created_at', ], - expense_mutations: ['actor_user_id', 'idempotency_key', 'operation', 'resource_id', 'request_hash', 'response_json', 'created_at'], }; for (const [table, expected] of Object.entries(expectedColumns)) { const actual = columns(db, table); - for (const column of expected) assertIncludesOrThrow(actual, column, `${table}.${column} exists`); + for (const column of expected) + assertIncludesOrThrow(actual, column, `${table}.${column} exists`); } - assertIncludesOrThrow(indexNames(db, 'expense_categories'), 'idx_expense_categories_name_key', 'category names have a unique index'); - assertIncludesOrThrow(indexNames(db, 'expenses'), 'idx_expenses_replaces_unique', 'expenses have a one-replacement-per-source index'); - assertIncludesOrThrow(indexNames(db, 'expenses'), 'idx_expenses_incurred', 'expenses have an incurred-date list index'); - assertIncludesOrThrow(indexNames(db, 'expenses'), 'idx_expenses_category', 'expenses have a category list index'); - assertIncludesOrThrow(indexNames(db, 'expense_payments'), 'idx_expense_payments_reversal_unique', 'a payment cannot be reversed twice'); - assertIncludesOrThrow(indexNames(db, 'expense_payments'), 'idx_expense_payments_cash_movement', 'a cash movement cannot back two payment entries'); - assertIncludesOrThrow(indexNames(db, 'expense_payments'), 'idx_expense_payments_expense', 'payments have an expense-ledger index'); + assertIncludesOrThrow( + indexNames(db, 'expense_categories'), + 'idx_expense_categories_name_key', + 'category names have a unique index', + ); + assertIncludesOrThrow( + indexNames(db, 'expenses'), + 'idx_expenses_replaces_unique', + 'expenses have a one-replacement-per-source index', + ); + assertIncludesOrThrow( + indexNames(db, 'expenses'), + 'idx_expenses_incurred', + 'expenses have an incurred-date list index', + ); + assertIncludesOrThrow( + indexNames(db, 'expenses'), + 'idx_expenses_category', + 'expenses have a category list index', + ); + assertIncludesOrThrow( + indexNames(db, 'expense_payments'), + 'idx_expense_payments_reversal_unique', + 'a payment cannot be reversed twice', + ); + assertIncludesOrThrow( + indexNames(db, 'expense_payments'), + 'idx_expense_payments_cash_movement', + 'a cash movement cannot back two payment entries', + ); + assertIncludesOrThrow( + indexNames(db, 'expense_payments'), + 'idx_expense_payments_expense', + 'payments have an expense-ledger index', + ); // ── Native constraints reject malformed ledger rows ── const ts = now(); db.prepare(`INSERT INTO expense_categories (id, name, name_key, is_active, created_by, updated_by, created_at, updated_at) - VALUES ('schema-cat', 'Utilities', 'utilities', 1, 'exp-owner', 'exp-owner', ?, ?)`).run(ts, ts); - assertOrThrow(throwsSqlite(() => db.prepare(`INSERT INTO expense_categories (id, name, name_key, is_active, created_by, updated_by, created_at, updated_at) - VALUES ('schema-cat-dup', 'UTILITIES', 'utilities', 1, 'exp-owner', 'exp-owner', ?, ?)`).run(ts, ts)), - 'a second category with the same normalized name is rejected'); - assertOrThrow(throwsSqlite(() => db.prepare(`INSERT INTO expense_categories (id, name, name_key, is_active, created_by, updated_by, created_at, updated_at) - VALUES ('schema-cat-fk', 'Ghost', 'ghost', 1, 'missing-user', 'missing-user', ?, ?)`).run(ts, ts)), - 'a category cannot reference an unknown actor'); - - const insertExpense = (id: string, amountMinor: number, extra: { currency?: string; replaces?: string | null } = {}) => - db.prepare(`INSERT INTO expenses ( + VALUES ('schema-cat', 'Utilities', 'utilities', 1, 'exp-owner', 'exp-owner', ?, ?)`).run( + ts, + ts, + ); + assertOrThrow( + throwsSqlite(() => + db + .prepare(`INSERT INTO expense_categories (id, name, name_key, is_active, created_by, updated_by, created_at, updated_at) + VALUES ('schema-cat-dup', 'UTILITIES', 'utilities', 1, 'exp-owner', 'exp-owner', ?, ?)`) + .run(ts, ts), + ), + 'a second category with the same normalized name is rejected', + ); + assertOrThrow( + throwsSqlite(() => + db + .prepare(`INSERT INTO expense_categories (id, name, name_key, is_active, created_by, updated_by, created_at, updated_at) + VALUES ('schema-cat-fk', 'Ghost', 'ghost', 1, 'missing-user', 'missing-user', ?, ?)`) + .run(ts, ts), + ), + 'a category cannot reference an unknown actor', + ); + + const insertExpense = ( + id: string, + amountMinor: number, + extra: { currency?: string; replaces?: string | null } = {}, + ) => + db + .prepare(`INSERT INTO expenses ( id, category_id, category_name, description, amount_minor, currency_code, incurred_on, created_by, created_at, replaces_expense_id ) VALUES (?, 'schema-cat', 'Utilities', 'Fixture expense', ?, ?, '2026-10-01', 'exp-owner', ?, ?)`) .run(id, amountMinor, extra.currency ?? 'THB', ts, extra.replaces ?? null); insertExpense('schema-exp-1', 1500); - assertOrThrow(throwsSqlite(() => insertExpense('schema-exp-zero', 0)), 'a zero expense amount is rejected'); - assertOrThrow(throwsSqlite(() => insertExpense('schema-exp-neg', -5)), 'a negative expense amount is rejected'); - assertOrThrow(throwsSqlite(() => insertExpense('schema-exp-currency', 100, { currency: 'TH' })), 'a short currency code is rejected'); - assertOrThrow(throwsSqlite(() => db.prepare(`INSERT INTO expenses ( + assertOrThrow( + throwsSqlite(() => insertExpense('schema-exp-zero', 0)), + 'a zero expense amount is rejected', + ); + assertOrThrow( + throwsSqlite(() => insertExpense('schema-exp-neg', -5)), + 'a negative expense amount is rejected', + ); + assertOrThrow( + throwsSqlite(() => insertExpense('schema-exp-currency', 100, { currency: 'TH' })), + 'a short currency code is rejected', + ); + assertOrThrow( + throwsSqlite(() => + db + .prepare(`INSERT INTO expenses ( id, category_id, category_name, description, amount_minor, currency_code, incurred_on, created_by, created_at - ) VALUES ('schema-exp-bad-date', 'schema-cat', 'Utilities', 'x', 100, 'THB', '01-10-2026', 'exp-owner', ?)`).run(ts)), - 'a non-ISO incurred date is rejected'); + ) VALUES ('schema-exp-bad-date', 'schema-cat', 'Utilities', 'x', 100, 'THB', '01-10-2026', 'exp-owner', ?)`) + .run(ts), + ), + 'a non-ISO incurred date is rejected', + ); insertExpense('schema-exp-2', 700, { replaces: 'schema-exp-1' }); - assertOrThrow(throwsSqlite(() => insertExpense('schema-exp-3', 700, { replaces: 'schema-exp-1' })), - 'two expenses cannot replace the same source expense'); + assertOrThrow( + throwsSqlite(() => insertExpense('schema-exp-3', 700, { replaces: 'schema-exp-1' })), + 'two expenses cannot replace the same source expense', + ); const insertPayment = ( id: string, - extra: { reversalOf?: string | null; reason?: string | null; cashMovementId?: number | null; method?: string } = {}, - ) => db.prepare(`INSERT INTO expense_payments ( + extra: { + reversalOf?: string | null; + reason?: string | null; + cashMovementId?: number | null; + method?: string; + } = {}, + ) => + db + .prepare(`INSERT INTO expense_payments ( id, expense_id, amount_minor, method, business_date, reversal_of, reason, cash_movement_id, created_by, created_at ) VALUES (?, 'schema-exp-1', 500, ?, '2026-10-01', ?, ?, ?, 'exp-owner', ?)`) - .run(id, extra.method ?? 'cash', extra.reversalOf ?? null, extra.reason ?? null, extra.cashMovementId ?? null, ts); + .run( + id, + extra.method ?? 'cash', + extra.reversalOf ?? null, + extra.reason ?? null, + extra.cashMovementId ?? null, + ts, + ); insertPayment('schema-pay-1'); - assertOrThrow(throwsSqlite(() => insertPayment('schema-pay-bad-method', { method: 'loyalty' })), 'an unsupported payment method is rejected'); - assertOrThrow(throwsSqlite(() => insertPayment('schema-pay-no-reason', { reversalOf: 'schema-pay-1' })), - 'a reversal without a reason is rejected'); - assertOrThrow(throwsSqlite(() => insertPayment('schema-pay-orphan-reason', { reason: 'not a reversal' })), - 'a payment carrying a reversal reason without a reversal reference is rejected'); + assertOrThrow( + throwsSqlite(() => insertPayment('schema-pay-bad-method', { method: 'loyalty' })), + 'an unsupported payment method is rejected', + ); + assertOrThrow( + throwsSqlite(() => insertPayment('schema-pay-no-reason', { reversalOf: 'schema-pay-1' })), + 'a reversal without a reason is rejected', + ); + assertOrThrow( + throwsSqlite(() => insertPayment('schema-pay-orphan-reason', { reason: 'not a reversal' })), + 'a payment carrying a reversal reason without a reversal reference is rejected', + ); insertPayment('schema-pay-rev-1', { reversalOf: 'schema-pay-1', reason: 'Recorded in error' }); - assertOrThrow(throwsSqlite(() => insertPayment('schema-pay-rev-2', { reversalOf: 'schema-pay-1', reason: 'Again' })), - 'a payment cannot be reversed twice'); + assertOrThrow( + throwsSqlite(() => + insertPayment('schema-pay-rev-2', { reversalOf: 'schema-pay-1', reason: 'Again' }), + ), + 'a payment cannot be reversed twice', + ); const movementId = Number(db.prepare(`SELECT id FROM cash_drawer_movements LIMIT 1`).get().id); insertPayment('schema-pay-cash-linked', { cashMovementId: movementId }); - assertOrThrow(throwsSqlite(() => insertPayment('schema-pay-cash-dup', { cashMovementId: movementId })), - 'one cash movement cannot back two payment entries'); + assertOrThrow( + throwsSqlite(() => insertPayment('schema-pay-cash-dup', { cashMovementId: movementId })), + 'one cash movement cannot back two payment entries', + ); db.prepare(`INSERT INTO expense_mutations (actor_user_id, idempotency_key, operation, resource_id, request_hash, response_json, created_at) - VALUES ('exp-owner', 'schema-key-1', 'create_expense', 'schema-exp-1', 'hash', '{}', ?)`).run(ts); - assertOrThrow(throwsSqlite(() => db.prepare(`INSERT INTO expense_mutations (actor_user_id, idempotency_key, operation, resource_id, request_hash, response_json, created_at) - VALUES ('exp-owner', 'schema-key-1', 'void_expense', 'schema-exp-1', 'hash2', '{}', ?)`).run(ts)), - 'a mutation receipt is unique per actor and idempotency key'); + VALUES ('exp-owner', 'schema-key-1', 'create_expense', 'schema-exp-1', 'hash', '{}', ?)`).run( + ts, + ); + assertOrThrow( + throwsSqlite(() => + db + .prepare(`INSERT INTO expense_mutations (actor_user_id, idempotency_key, operation, resource_id, request_hash, response_json, created_at) + VALUES ('exp-owner', 'schema-key-1', 'void_expense', 'schema-exp-1', 'hash2', '{}', ?)`) + .run(ts), + ), + 'a mutation receipt is unique per actor and idempotency key', + ); runServiceChecks(db); @@ -293,7 +505,9 @@ try { fs.rmSync(testDir, { recursive: true, force: true }); console.log('✅ Expense records schema test passed'); } catch (error) { - try { closeDatabase(); } catch { } + try { + closeDatabase(); + } catch {} MIGRATIONS.length = 0; MIGRATIONS.push(...PRISTINE_MIGRATIONS); Module._load = originalLoad; @@ -304,9 +518,20 @@ try { /** Rows in the tables an expense workflow must never touch. */ function countRows(db: any): Record { - const tables = ['orders', 'order_items', 'bills', 'supplies', 'supply_movements', 'cash_sessions', 'cash_drawer_movements']; + const tables = [ + 'orders', + 'order_items', + 'bills', + 'supplies', + 'supply_movements', + 'cash_sessions', + 'cash_drawer_movements', + ]; const counts: Record = {}; - for (const table of tables) counts[table] = (db.prepare(`SELECT COUNT(*) AS count FROM ${table}`).get() as { count: number }).count; + for (const table of tables) + counts[table] = ( + db.prepare(`SELECT COUNT(*) AS count FROM ${table}`).get() as { count: number } + ).count; return counts; } @@ -315,7 +540,9 @@ function runServiceChecks(db: any): void { const service = require('../main/services/expenses'); const actor = 'exp-owner'; const setSetting = (key: string, value: string) => - db.prepare('INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES (?, ?, ?)').run(key, value, now()); + db + .prepare('INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES (?, ?, ?)') + .run(key, value, now()); setSetting('country', 'TH'); setSetting('currency', 'THB'); setSetting('timezone', 'Asia/Bangkok'); @@ -329,7 +556,12 @@ function runServiceChecks(db: any): void { db.prepare('DELETE FROM expenses').run(); db.prepare('DELETE FROM expense_categories').run(); - const expectError = (fn: () => unknown, statusCode: number, label: string, code: string | null = null) => { + const expectError = ( + fn: () => unknown, + statusCode: number, + label: string, + code: string | null = null, + ) => { try { fn(); assertOrThrow(false, `${label} (expected status ${statusCode}, nothing was thrown)`); @@ -340,45 +572,153 @@ function runServiceChecks(db: any): void { }; // ── Categories: unique trimmed names, explicit activity, no expense reclassification ── - const rent = service.createExpenseCategory(db, { name: ' Rent ', actorUserId: actor, idempotencyKey: 'cat-rent' }); + const rent = service.createExpenseCategory(db, { + name: ' Rent ', + actorUserId: actor, + idempotencyKey: 'cat-rent', + }); assertEqualOrThrow(rent.status, 201, 'category create returns 201'); assertEqualOrThrow(rent.body.category.name, 'Rent', 'category names are stored trimmed'); assertEqualOrThrow(rent.body.category.is_active, 1, 'a new category is active'); - assertEqualOrThrow(rent.body.category.created_by, actor, 'category creation attributes the authenticated actor'); - assertOrThrow(rent.body.category.id.startsWith('expcat_'), 'category ids use the repo UUID convention'); + assertEqualOrThrow( + rent.body.category.created_by, + actor, + 'category creation attributes the authenticated actor', + ); + assertOrThrow( + rent.body.category.id.startsWith('expcat_'), + 'category ids use the repo UUID convention', + ); const rentId = rent.body.category.id; - const rentReplay = service.createExpenseCategory(db, { name: 'Rent', actorUserId: actor, idempotencyKey: 'cat-rent' }); - assertEqualOrThrow(rentReplay.replayed, true, 'the same category key and payload replays the committed result'); - assertEqualOrThrow(rentReplay.body.category.id, rentId, 'the replay returns the original category id'); + const rentReplay = service.createExpenseCategory(db, { + name: 'Rent', + actorUserId: actor, + idempotencyKey: 'cat-rent', + }); + assertEqualOrThrow( + rentReplay.replayed, + true, + 'the same category key and payload replays the committed result', + ); assertEqualOrThrow( - (db.prepare('SELECT COUNT(*) AS count FROM expense_categories').get() as { count: number }).count, + rentReplay.body.category.id, + rentId, + 'the replay returns the original category id', + ); + assertEqualOrThrow( + (db.prepare('SELECT COUNT(*) AS count FROM expense_categories').get() as { count: number }) + .count, 1, 'a replayed category mutation writes nothing new', ); - expectError(() => service.createExpenseCategory(db, { name: 'Rent renamed', actorUserId: actor, idempotencyKey: 'cat-rent' }), 409, 'a reused key with a different payload conflicts', 'idempotency_conflict'); - expectError(() => service.createExpenseCategory(db, { name: ' rent ', actorUserId: actor, idempotencyKey: 'cat-rent-2' }), 409, 'category names are unique case-insensitively after trimming', 'category_name_taken'); - expectError(() => service.createExpenseCategory(db, { name: ' ', actorUserId: actor, idempotencyKey: 'cat-blank' }), 400, 'a blank category name is rejected'); - expectError(() => service.createExpenseCategory(db, { name: 'x'.repeat(81), actorUserId: actor, idempotencyKey: 'cat-long' }), 400, 'an over-long category name is rejected'); + expectError( + () => + service.createExpenseCategory(db, { + name: 'Rent renamed', + actorUserId: actor, + idempotencyKey: 'cat-rent', + }), + 409, + 'a reused key with a different payload conflicts', + 'idempotency_conflict', + ); + expectError( + () => + service.createExpenseCategory(db, { + name: ' rent ', + actorUserId: actor, + idempotencyKey: 'cat-rent-2', + }), + 409, + 'category names are unique case-insensitively after trimming', + 'category_name_taken', + ); + expectError( + () => + service.createExpenseCategory(db, { + name: ' ', + actorUserId: actor, + idempotencyKey: 'cat-blank', + }), + 400, + 'a blank category name is rejected', + ); + expectError( + () => + service.createExpenseCategory(db, { + name: 'x'.repeat(81), + actorUserId: actor, + idempotencyKey: 'cat-long', + }), + 400, + 'an over-long category name is rejected', + ); - const utilities = service.createExpenseCategory(db, { name: 'Utilities', actorUserId: actor, idempotencyKey: 'cat-utilities' }); + const utilities = service.createExpenseCategory(db, { + name: 'Utilities', + actorUserId: actor, + idempotencyKey: 'cat-utilities', + }); const utilitiesId = utilities.body.category.id; - const deactivated = service.updateExpenseCategory(db, utilitiesId, { is_active: false, actorUserId: actor, idempotencyKey: 'cat-utilities-off' }); - assertEqualOrThrow(deactivated.body.category.is_active, 0, 'a category can be deactivated explicitly'); - assertEqualOrThrow(deactivated.body.category.updated_by, actor, 'category updates attribute the authenticated actor'); + const deactivated = service.updateExpenseCategory(db, utilitiesId, { + is_active: false, + actorUserId: actor, + idempotencyKey: 'cat-utilities-off', + }); + assertEqualOrThrow( + deactivated.body.category.is_active, + 0, + 'a category can be deactivated explicitly', + ); + assertEqualOrThrow( + deactivated.body.category.updated_by, + actor, + 'category updates attribute the authenticated actor', + ); assertOrThrow( !service.listExpenseCategories(db).categories.some((row: any) => row.id === utilitiesId), 'the default category list hides inactive categories', ); assertOrThrow( - service.listExpenseCategories(db, { includeInactive: true }).categories.some((row: any) => row.id === utilitiesId), + service + .listExpenseCategories(db, { includeInactive: true }) + .categories.some((row: any) => row.id === utilitiesId), 'include_inactive keeps deactivated categories visible for history', ); - expectError(() => service.updateExpenseCategory(db, rentId, { actorUserId: actor, idempotencyKey: 'cat-noop' }), 400, 'a category patch without name or is_active is rejected'); - expectError(() => service.updateExpenseCategory(db, 'missing-category', { name: 'Ghost', actorUserId: actor, idempotencyKey: 'cat-missing' }), 404, 'patching an unknown category is a 404'); - expectError(() => service.updateExpenseCategory(db, rentId, { name: 'Utilities', actorUserId: actor, idempotencyKey: 'cat-collide' }), 409, 'renaming onto an existing category name conflicts', 'category_name_taken'); + expectError( + () => + service.updateExpenseCategory(db, rentId, { actorUserId: actor, idempotencyKey: 'cat-noop' }), + 400, + 'a category patch without name or is_active is rejected', + ); + expectError( + () => + service.updateExpenseCategory(db, 'missing-category', { + name: 'Ghost', + actorUserId: actor, + idempotencyKey: 'cat-missing', + }), + 404, + 'patching an unknown category is a 404', + ); + expectError( + () => + service.updateExpenseCategory(db, rentId, { + name: 'Utilities', + actorUserId: actor, + idempotencyKey: 'cat-collide', + }), + 409, + 'renaming onto an existing category name conflicts', + 'category_name_taken', + ); - const renamed = service.updateExpenseCategory(db, rentId, { name: 'Rent & Lease', actorUserId: actor, idempotencyKey: 'cat-rent-rename' }); + const renamed = service.updateExpenseCategory(db, rentId, { + name: 'Rent & Lease', + actorUserId: actor, + idempotencyKey: 'cat-rent-rename', + }); assertEqualOrThrow(renamed.body.category.name, 'Rent & Lease', 'the category rename is stored'); // ── Expense creation ── @@ -394,88 +734,275 @@ function runServiceChecks(db: any): void { ...overrides, }); - const created = service.createExpense(db, createInput({ - category_id: ` ${rentId} `, - description: ' October rent ', - currency_code: ' thb ', - payee: ' Landlord ', - notes: ' Q4 payment ', - })); + const created = service.createExpense( + db, + createInput({ + category_id: ` ${rentId} `, + description: ' October rent ', + currency_code: ' thb ', + payee: ' Landlord ', + notes: ' Q4 payment ', + }), + ); assertEqualOrThrow(created.status, 201, 'expense create returns 201'); assertEqualOrThrow(created.body.expense.paid_minor, 0, 'a new expense never starts paid'); - assertEqualOrThrow(created.body.expense.due_minor, 1500000, 'the full amount is due on a new expense'); - assertEqualOrThrow(created.body.expense.amount_minor, 1500000, 'minor units round-trip unchanged'); - assertEqualOrThrow(created.body.expense.currency_code, 'THB', 'the expense snapshots the store currency'); + assertEqualOrThrow( + created.body.expense.due_minor, + 1500000, + 'the full amount is due on a new expense', + ); + assertEqualOrThrow( + created.body.expense.amount_minor, + 1500000, + 'minor units round-trip unchanged', + ); + assertEqualOrThrow( + created.body.expense.currency_code, + 'THB', + 'the expense snapshots the store currency', + ); assertEqualOrThrow(created.body.expense.status, 'active', 'a new expense is active'); - assertEqualOrThrow(created.body.expense.category_name, 'Rent & Lease', 'the category label is snapshotted onto the expense'); - assertEqualOrThrow(created.body.expense.created_by, actor, 'expense creation attributes the authenticated actor'); - assertOrThrow(created.body.expense.id.startsWith('exp_'), 'expense ids use the repo UUID convention'); + assertEqualOrThrow( + created.body.expense.category_name, + 'Rent & Lease', + 'the category label is snapshotted onto the expense', + ); + assertEqualOrThrow( + created.body.expense.created_by, + actor, + 'expense creation attributes the authenticated actor', + ); + assertOrThrow( + created.body.expense.id.startsWith('exp_'), + 'expense ids use the repo UUID convention', + ); const rentExpenseId = created.body.expense.id; const createReplay = service.createExpense(db, createInput({ currency_code: 'THB' })); - assertEqualOrThrow(createReplay.replayed, true, 'the same expense key and payload replays the committed result'); - assertEqualOrThrow(createReplay.body.expense.id, rentExpenseId, 'the replay returns the original expense id'); + assertEqualOrThrow( + createReplay.replayed, + true, + 'the same expense key and payload replays the committed result', + ); + assertEqualOrThrow( + createReplay.body.expense.id, + rentExpenseId, + 'the replay returns the original expense id', + ); // Creating an expense with currency omitted replays committed record even after store currency switches - const omittedInput = createInput({ idempotencyKey: 'exp-omitted-curr', currency_code: undefined, amount_minor: 50000 }); + const omittedInput = createInput({ + idempotencyKey: 'exp-omitted-curr', + currency_code: undefined, + amount_minor: 50000, + }); const omittedCreated = service.createExpense(db, omittedInput); assertEqualOrThrow(omittedCreated.status, 201, 'expense created with omitted currency'); setSetting('currency', 'JPY'); const omittedReplay = service.createExpense(db, omittedInput); - assertEqualOrThrow(omittedReplay.replayed, true, 'omitted currency_code replays committed expense after store currency switch'); - assertEqualOrThrow(omittedReplay.body.expense.id, omittedCreated.body.expense.id, 'omitted currency replay returns committed id'); + assertEqualOrThrow( + omittedReplay.replayed, + true, + 'omitted currency_code replays committed expense after store currency switch', + ); + assertEqualOrThrow( + omittedReplay.body.expense.id, + omittedCreated.body.expense.id, + 'omitted currency replay returns committed id', + ); const explicitReplay = service.createExpense(db, { ...omittedInput, currency_code: ' thb ' }); - assertEqualOrThrow(explicitReplay.replayed, true, 'the original explicit currency is equivalent to omission after currency changes'); - expectError(() => service.createExpense(db, { ...omittedInput, currency_code: 'JPY' }), 409, 'a changed explicit creation currency conflicts', 'idempotency_conflict'); + assertEqualOrThrow( + explicitReplay.replayed, + true, + 'the original explicit currency is equivalent to omission after currency changes', + ); + expectError( + () => service.createExpense(db, { ...omittedInput, currency_code: 'JPY' }), + 409, + 'a changed explicit creation currency conflicts', + 'idempotency_conflict', + ); setSetting('currency', 'THB'); - expectError(() => service.createExpense(db, createInput({ amount_minor: 999 })), 409, 'a reused expense key with a changed amount conflicts', 'idempotency_conflict'); - expectError(() => service.createExpense(db, createInput({ idempotencyKey: 'exp-rent-other', replaces_expense_id: rentExpenseId })), 400, 'replace linkage cannot be set through create'); + expectError( + () => service.createExpense(db, createInput({ amount_minor: 999 })), + 409, + 'a reused expense key with a changed amount conflicts', + 'idempotency_conflict', + ); + expectError( + () => + service.createExpense( + db, + createInput({ idempotencyKey: 'exp-rent-other', replaces_expense_id: rentExpenseId }), + ), + 400, + 'replace linkage cannot be set through create', + ); - service.updateExpenseCategory(db, rentId, { name: 'Rent 2026', actorUserId: actor, idempotencyKey: 'cat-rent-2026' }); + service.updateExpenseCategory(db, rentId, { + name: 'Rent 2026', + actorUserId: actor, + idempotencyKey: 'cat-rent-2026', + }); assertEqualOrThrow( service.getExpense(db, rentExpenseId).category_name, 'Rent & Lease', 'renaming a category never rewrites an existing expense label', ); expectError( - () => service.createExpense(db, createInput({ idempotencyKey: 'exp-inactive', category_id: utilitiesId })), + () => + service.createExpense( + db, + createInput({ idempotencyKey: 'exp-inactive', category_id: utilitiesId }), + ), 409, 'a deactivated category cannot receive a new expense', 'category_inactive', ); - expectError(() => service.createExpense(db, createInput({ idempotencyKey: 'exp-unknown-cat', category_id: 'missing-category' })), 404, 'an unknown category is a 404'); + expectError( + () => + service.createExpense( + db, + createInput({ idempotencyKey: 'exp-unknown-cat', category_id: 'missing-category' }), + ), + 404, + 'an unknown category is a 404', + ); // ── Amount, date, and text validation ── const validationKey = (label: string) => `exp-invalid-${label}`; - expectError(() => service.createExpense(db, createInput({ amount_minor: 0, idempotencyKey: validationKey('zero') })), 400, 'a zero amount is rejected'); - expectError(() => service.createExpense(db, createInput({ amount_minor: -1, idempotencyKey: validationKey('negative') })), 400, 'a negative amount is rejected'); - expectError(() => service.createExpense(db, createInput({ amount_minor: 12.5, idempotencyKey: validationKey('fractional') })), 400, 'a fractional minor-unit amount is rejected'); - expectError(() => service.createExpense(db, createInput({ amount_minor: '1500000', idempotencyKey: validationKey('string') })), 400, 'a string amount is rejected instead of coerced'); - expectError(() => service.createExpense(db, createInput({ amount_minor: Number.MAX_SAFE_INTEGER + 1, idempotencyKey: validationKey('unsafe') })), 400, 'an unsafe integer amount is rejected'); - expectError(() => service.createExpense(db, createInput({ description: ' ', idempotencyKey: validationKey('blank-desc') })), 400, 'a blank description is rejected'); - expectError(() => service.createExpense(db, createInput({ description: 'x'.repeat(201), idempotencyKey: validationKey('long-desc') })), 400, 'an over-long description is rejected'); - expectError(() => service.createExpense(db, createInput({ payee: 'x'.repeat(201), idempotencyKey: validationKey('long-payee') })), 400, 'an over-long payee is rejected'); - expectError(() => service.createExpense(db, createInput({ notes: 'x'.repeat(501), idempotencyKey: validationKey('long-notes') })), 400, 'over-long notes are rejected'); - assertEqualOrThrow(service.isValidBusinessDate('2024-02-29'), true, 'a real leap day is a valid business date'); - assertEqualOrThrow(service.isValidBusinessDate('2025-02-29'), false, 'a non-leap 29 February is rejected'); - assertEqualOrThrow(service.isValidBusinessDate('2026-04-31'), false, 'an impossible day of month is rejected'); + expectError( + () => + service.createExpense( + db, + createInput({ amount_minor: 0, idempotencyKey: validationKey('zero') }), + ), + 400, + 'a zero amount is rejected', + ); + expectError( + () => + service.createExpense( + db, + createInput({ amount_minor: -1, idempotencyKey: validationKey('negative') }), + ), + 400, + 'a negative amount is rejected', + ); + expectError( + () => + service.createExpense( + db, + createInput({ amount_minor: 12.5, idempotencyKey: validationKey('fractional') }), + ), + 400, + 'a fractional minor-unit amount is rejected', + ); + expectError( + () => + service.createExpense( + db, + createInput({ amount_minor: '1500000', idempotencyKey: validationKey('string') }), + ), + 400, + 'a string amount is rejected instead of coerced', + ); + expectError( + () => + service.createExpense( + db, + createInput({ + amount_minor: Number.MAX_SAFE_INTEGER + 1, + idempotencyKey: validationKey('unsafe'), + }), + ), + 400, + 'an unsafe integer amount is rejected', + ); + expectError( + () => + service.createExpense( + db, + createInput({ description: ' ', idempotencyKey: validationKey('blank-desc') }), + ), + 400, + 'a blank description is rejected', + ); + expectError( + () => + service.createExpense( + db, + createInput({ description: 'x'.repeat(201), idempotencyKey: validationKey('long-desc') }), + ), + 400, + 'an over-long description is rejected', + ); + expectError( + () => + service.createExpense( + db, + createInput({ payee: 'x'.repeat(201), idempotencyKey: validationKey('long-payee') }), + ), + 400, + 'an over-long payee is rejected', + ); + expectError( + () => + service.createExpense( + db, + createInput({ notes: 'x'.repeat(501), idempotencyKey: validationKey('long-notes') }), + ), + 400, + 'over-long notes are rejected', + ); + assertEqualOrThrow( + service.isValidBusinessDate('2024-02-29'), + true, + 'a real leap day is a valid business date', + ); + assertEqualOrThrow( + service.isValidBusinessDate('2025-02-29'), + false, + 'a non-leap 29 February is rejected', + ); + assertEqualOrThrow( + service.isValidBusinessDate('2026-04-31'), + false, + 'an impossible day of month is rejected', + ); for (const bad of ['2026-02-30', '2026-13-01', '01-02-2026', '2026-1-1', '', 'today']) { expectError( - () => service.createExpense(db, createInput({ incurred_on: bad, idempotencyKey: validationKey(`date-${bad || 'empty'}`) })), + () => + service.createExpense( + db, + createInput({ + incurred_on: bad, + idempotencyKey: validationKey(`date-${bad || 'empty'}`), + }), + ), 400, `the date ${JSON.stringify(bad)} is rejected`, ); } assertEqualOrThrow( - (db.prepare('SELECT COUNT(*) AS count FROM expense_mutations').get() as { count: number }).count, - db.prepare('SELECT COUNT(DISTINCT idempotency_key) AS count FROM expense_mutations').get().count, + (db.prepare('SELECT COUNT(*) AS count FROM expense_mutations').get() as { count: number }) + .count, + db.prepare('SELECT COUNT(DISTINCT idempotency_key) AS count FROM expense_mutations').get() + .count, 'no failed mutation is recorded as a committed receipt', ); const future = new Date(`${today}T00:00:00Z`); future.setUTCDate(future.getUTCDate() + 1); expectError( - () => service.createExpense(db, createInput({ incurred_on: future.toISOString().slice(0, 10), idempotencyKey: validationKey('future') })), + () => + service.createExpense( + db, + createInput({ + incurred_on: future.toISOString().slice(0, 10), + idempotencyKey: validationKey('future'), + }), + ), 400, 'a future expense date is rejected', ); @@ -483,14 +1010,40 @@ function runServiceChecks(db: any): void { // ahead of UTC is allowed to record its own (later) date but not the next one. const { localDateInTimezone, tenantBusinessDayStartTime } = require('../main/db'); setSetting('timezone', 'Pacific/Kiritimati'); - const storeDate = localDateInTimezone(new Date(), 'Pacific/Kiritimati', tenantBusinessDayStartTime()); - assertEqualOrThrow(service.currentBusinessDate(), storeDate, 'the business date follows the store timezone, not the host clock'); - const atStoreDate = service.createExpense(db, createInput({ incurred_on: storeDate, amount_minor: 500, idempotencyKey: validationKey('store-date') })); - assertEqualOrThrow(atStoreDate.body.expense.incurred_on, storeDate, 'an expense dated to the store business date is accepted'); + const storeDate = localDateInTimezone( + new Date(), + 'Pacific/Kiritimati', + tenantBusinessDayStartTime(), + ); + assertEqualOrThrow( + service.currentBusinessDate(), + storeDate, + 'the business date follows the store timezone, not the host clock', + ); + const atStoreDate = service.createExpense( + db, + createInput({ + incurred_on: storeDate, + amount_minor: 500, + idempotencyKey: validationKey('store-date'), + }), + ); + assertEqualOrThrow( + atStoreDate.body.expense.incurred_on, + storeDate, + 'an expense dated to the store business date is accepted', + ); const pastStoreDate = new Date(`${storeDate}T00:00:00Z`); pastStoreDate.setUTCDate(pastStoreDate.getUTCDate() + 1); expectError( - () => service.createExpense(db, createInput({ incurred_on: pastStoreDate.toISOString().slice(0, 10), idempotencyKey: validationKey('store-date-next') })), + () => + service.createExpense( + db, + createInput({ + incurred_on: pastStoreDate.toISOString().slice(0, 10), + idempotencyKey: validationKey('store-date-next'), + }), + ), 400, 'the day after the store business date is still future', ); @@ -498,9 +1051,24 @@ function runServiceChecks(db: any): void { const historical = new Date(`${today}T00:00:00Z`); historical.setUTCDate(historical.getUTCDate() - 45); const historicalDate = historical.toISOString().slice(0, 10); - const backdated = service.createExpense(db, createInput({ incurred_on: historicalDate, amount_minor: 250000, idempotencyKey: validationKey('historical') })); - assertEqualOrThrow(backdated.body.expense.incurred_on, historicalDate, 'an expense may be dated to an earlier, already-closed business day'); - assertEqualOrThrow(backdated.body.expense.paid_minor, 0, 'a backdated expense is not assumed paid'); + const backdated = service.createExpense( + db, + createInput({ + incurred_on: historicalDate, + amount_minor: 250000, + idempotencyKey: validationKey('historical'), + }), + ); + assertEqualOrThrow( + backdated.body.expense.incurred_on, + historicalDate, + 'an expense may be dated to an earlier, already-closed business day', + ); + assertEqualOrThrow( + backdated.body.expense.paid_minor, + 0, + 'a backdated expense is not assumed paid', + ); assertEqualOrThrow( backdated.body.expense.created_at.slice(0, 10), new Date().toISOString().slice(0, 10), @@ -511,9 +1079,20 @@ function runServiceChecks(db: any): void { const originalCurrency = service.getExpense(db, rentExpenseId).currency_code; for (const currency of ['JPY', 'KWD', 'THB']) { setSetting('currency', currency); - const row = service.createExpense(db, createInput({ amount_minor: 1500, idempotencyKey: `exp-currency-${currency}` })); - assertEqualOrThrow(row.body.expense.currency_code, currency, `an expense created under ${currency} snapshots ${currency}`); - assertEqualOrThrow(row.body.expense.amount_minor, 1500, `${currency} minor units round-trip without scaling`); + const row = service.createExpense( + db, + createInput({ amount_minor: 1500, idempotencyKey: `exp-currency-${currency}` }), + ); + assertEqualOrThrow( + row.body.expense.currency_code, + currency, + `an expense created under ${currency} snapshots ${currency}`, + ); + assertEqualOrThrow( + row.body.expense.amount_minor, + 1500, + `${currency} minor units round-trip without scaling`, + ); assertEqualOrThrow( service.getExpense(db, rentExpenseId).currency_code, originalCurrency, @@ -521,11 +1100,23 @@ function runServiceChecks(db: any): void { ); } expectError( - () => service.createExpense(db, createInput({ currency_code: 'USD', idempotencyKey: validationKey('foreign-currency') })), + () => + service.createExpense( + db, + createInput({ currency_code: 'USD', idempotencyKey: validationKey('foreign-currency') }), + ), 400, 'an expense in a currency other than the store currency is rejected', ); - expectError(() => service.createExpense(db, createInput({ currency_code: 'TH', idempotencyKey: validationKey('short-currency') })), 400, 'a malformed currency code is rejected'); + expectError( + () => + service.createExpense( + db, + createInput({ currency_code: 'TH', idempotencyKey: validationKey('short-currency') }), + ), + 400, + 'a malformed currency code is rejected', + ); // ── Unconfigured regional settings fail loudly instead of guessing ── const savedRegion = { @@ -533,38 +1124,106 @@ function runServiceChecks(db: any): void { currency: db.prepare(`SELECT value FROM settings WHERE key = 'currency'`).get()?.value, timezone: db.prepare(`SELECT value FROM settings WHERE key = 'timezone'`).get()?.value, }; - for (const key of ['country', 'currency', 'timezone']) db.prepare('DELETE FROM settings WHERE key = ?').run(key); - expectError(() => service.createExpense(db, createInput({ idempotencyKey: validationKey('unconfigured') })), 409, 'an unconfigured store cannot record an expense'); - expectError(() => service.getExpenseContext(db), 409, 'context refuses to invent a currency before setup'); + for (const key of ['country', 'currency', 'timezone']) + db.prepare('DELETE FROM settings WHERE key = ?').run(key); + expectError( + () => service.createExpense(db, createInput({ idempotencyKey: validationKey('unconfigured') })), + 409, + 'an unconfigured store cannot record an expense', + ); + expectError( + () => service.getExpenseContext(db), + 409, + 'context refuses to invent a currency before setup', + ); setSetting('country', savedRegion.country); setSetting('currency', savedRegion.currency); setSetting('timezone', savedRegion.timezone); - assertEqualOrThrow(service.getExpenseContext(db).currency_code, savedRegion.currency, 'context reports the configured store currency'); - assertEqualOrThrow(service.getExpenseContext(db).business_date, today, 'context reports the store business date, not the host date'); - assertEqualOrThrow(service.getExpenseContext(db).cash_session_open, true, 'context reports the fixture open cash session'); - assertEqualOrThrow(Object.keys(service.getExpenseContext(db)).sort().join(','), 'business_date,cash_session_open,currency_code', 'context exposes only currency, business date, and drawer state'); + assertEqualOrThrow( + service.getExpenseContext(db).currency_code, + savedRegion.currency, + 'context reports the configured store currency', + ); + assertEqualOrThrow( + service.getExpenseContext(db).business_date, + today, + 'context reports the store business date, not the host date', + ); + assertEqualOrThrow( + service.getExpenseContext(db).cash_session_open, + true, + 'context reports the fixture open cash session', + ); + assertEqualOrThrow( + Object.keys(service.getExpenseContext(db)).sort().join(','), + 'business_date,cash_session_open,currency_code', + 'context exposes only currency, business date, and drawer state', + ); // ── Payment ledger reads: seeded rows only, independent of the write path ── - const insertSeedPayment = (id: string, amountMinor: number, method: string, createdAt: string, extra: { reversalOf?: string; reason?: string; expenseId?: string } = {}) => - db.prepare(`INSERT INTO expense_payments (id, expense_id, amount_minor, method, business_date, reversal_of, reason, created_by, created_at) + const insertSeedPayment = ( + id: string, + amountMinor: number, + method: string, + createdAt: string, + extra: { reversalOf?: string; reason?: string; expenseId?: string } = {}, + ) => + db + .prepare(`INSERT INTO expense_payments (id, expense_id, amount_minor, method, business_date, reversal_of, reason, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`) - .run(id, extra.expenseId ?? rentExpenseId, amountMinor, method, today, extra.reversalOf ?? null, extra.reason ?? null, actor, createdAt); + .run( + id, + extra.expenseId ?? rentExpenseId, + amountMinor, + method, + today, + extra.reversalOf ?? null, + extra.reason ?? null, + actor, + createdAt, + ); insertSeedPayment('seed-pay-1', 400000, 'cash', `${today} 09:00:00`); insertSeedPayment('seed-pay-2', 350000, 'card', `${today} 10:00:00`); - insertSeedPayment('seed-pay-rev', 400000, 'cash', `${today} 11:00:00`, { reversalOf: 'seed-pay-1', reason: 'Cash returned to the drawer' }); + insertSeedPayment('seed-pay-rev', 400000, 'cash', `${today} 11:00:00`, { + reversalOf: 'seed-pay-1', + reason: 'Cash returned to the drawer', + }); const ledgerDetail = service.getExpense(db, rentExpenseId); assertEqualOrThrow(ledgerDetail.paid_minor, 350000, 'net paid is payments minus reversals'); assertEqualOrThrow(ledgerDetail.due_minor, 1500000 - 350000, 'due reconciles against net paid'); - assertEqualOrThrow(ledgerDetail.amount_minor, 1500000, 'the original amount survives payment history'); + assertEqualOrThrow( + ledgerDetail.amount_minor, + 1500000, + 'the original amount survives payment history', + ); const history = service.listExpensePayments(db, rentExpenseId); - assertEqualOrThrow(history.payments.length, 3, 'the ledger keeps reversals alongside their payments'); + assertEqualOrThrow( + history.payments.length, + 3, + 'the ledger keeps reversals alongside their payments', + ); assertEqualOrThrow(history.payments[0].id, 'seed-pay-rev', 'payment history is newest first'); - assertEqualOrThrow(history.payments[0].reversal_of, 'seed-pay-1', 'a reversal names the payment it reverses'); + assertEqualOrThrow( + history.payments[0].reversal_of, + 'seed-pay-1', + 'a reversal names the payment it reverses', + ); const historyPage = service.listExpensePayments(db, rentExpenseId, { limit: 2 }); assertEqualOrThrow(historyPage.payments.length, 2, 'payment history pages'); - assertEqualOrThrow(typeof historyPage.nextCursor, 'string', 'a truncated payment history exposes a cursor'); - const historyPage2 = service.listExpensePayments(db, rentExpenseId, { limit: 2, cursor: historyPage.nextCursor as string }); - assertEqualOrThrow(historyPage2.payments.length, 1, 'the payment-history cursor returns the remaining row'); + assertEqualOrThrow( + typeof historyPage.nextCursor, + 'string', + 'a truncated payment history exposes a cursor', + ); + const historyPage2 = service.listExpensePayments(db, rentExpenseId, { + limit: 2, + cursor: historyPage.nextCursor as string, + }); + assertEqualOrThrow( + historyPage2.payments.length, + 1, + 'the payment-history cursor returns the remaining row', + ); assertEqualOrThrow( new Set([...historyPage.payments, ...historyPage2.payments].map((row: any) => row.id)).size, 3, @@ -573,9 +1232,24 @@ function runServiceChecks(db: any): void { // ── Paging over the expense list ── for (let index = 0; index < 5; index++) { - service.createExpense(db, createInput({ description: `Paged expense ${index}`, amount_minor: 1000 + index, idempotencyKey: `exp-page-${index}` })); + service.createExpense( + db, + createInput({ + description: `Paged expense ${index}`, + amount_minor: 1000 + index, + idempotencyKey: `exp-page-${index}`, + }), + ); } - const inRangeCount = Number((db.prepare('SELECT COUNT(*) AS count FROM expenses WHERE category_id = ? AND voided_at IS NULL').get(rentId) as { count: number }).count); + const inRangeCount = Number( + ( + db + .prepare( + 'SELECT COUNT(*) AS count FROM expenses WHERE category_id = ? AND voided_at IS NULL', + ) + .get(rentId) as { count: number } + ).count, + ); const collected: string[] = []; const pageSizes: number[] = []; let cursor: string | null = null; @@ -594,17 +1268,45 @@ function runServiceChecks(db: any): void { const previous = ordered[index - 1]; const current = ordered[index]; assertOrThrow( - previous.incurred_on > current.incurred_on - || (previous.incurred_on === current.incurred_on && previous.id > current.id), + previous.incurred_on > current.incurred_on || + (previous.incurred_on === current.incurred_on && previous.id > current.id), `list order is stable at position ${index}`, ); } - expectError(() => service.listExpenses(db, { limit: 0 }), 400, 'a non-positive limit is rejected'); - expectError(() => service.listExpenses(db, { cursor: 'not-a-cursor' }), 400, 'a malformed cursor is rejected'); - expectError(() => service.listExpenses(db, { from: '2026-04-31' }), 400, 'a malformed range start is rejected'); - expectError(() => service.listExpenses(db, { from: today, to: '2026-01-01' }), 400, 'an inverted range is rejected'); - expectError(() => service.listExpenses(db, { status: 'paid' }), 400, 'an unknown status filter is rejected'); - const thbInCategory = Number((db.prepare(`SELECT COUNT(*) AS count FROM expenses WHERE category_id = ? AND voided_at IS NULL AND currency_code = 'THB'`).get(rentId) as { count: number }).count); + expectError( + () => service.listExpenses(db, { limit: 0 }), + 400, + 'a non-positive limit is rejected', + ); + expectError( + () => service.listExpenses(db, { cursor: 'not-a-cursor' }), + 400, + 'a malformed cursor is rejected', + ); + expectError( + () => service.listExpenses(db, { from: '2026-04-31' }), + 400, + 'a malformed range start is rejected', + ); + expectError( + () => service.listExpenses(db, { from: today, to: '2026-01-01' }), + 400, + 'an inverted range is rejected', + ); + expectError( + () => service.listExpenses(db, { status: 'paid' }), + 400, + 'an unknown status filter is rejected', + ); + const thbInCategory = Number( + ( + db + .prepare( + `SELECT COUNT(*) AS count FROM expenses WHERE category_id = ? AND voided_at IS NULL AND currency_code = 'THB'`, + ) + .get(rentId) as { count: number } + ).count, + ); assertEqualOrThrow( service.listExpenses(db, { currencyCode: 'thb', categoryId: rentId }).expenses.length, thbInCategory, @@ -614,82 +1316,267 @@ function runServiceChecks(db: any): void { // ── Summary: SQL aggregation over every matching expense, not just the fetched page ── const rangeFrom = historicalDate; const rangeTo = storeDate > today ? storeDate : today; - const summary = service.summarizeExpenses(db, { from: rangeFrom, to: rangeTo, categoryId: rentId }); - assertEqualOrThrow(summary.basis, 'active_expenses_incurred_in_range_paid_to_date', 'the summary labels its paid-to-date basis'); - assertEqualOrThrow(summary.filters.status, 'active', 'the summary always reports its active-expense basis'); + const summary = service.summarizeExpenses(db, { + from: rangeFrom, + to: rangeTo, + categoryId: rentId, + }); + assertEqualOrThrow( + summary.basis, + 'active_expenses_incurred_in_range_paid_to_date', + 'the summary labels its paid-to-date basis', + ); + assertEqualOrThrow( + summary.filters.status, + 'active', + 'the summary always reports its active-expense basis', + ); assertEqualOrThrow(summary.groups.length, 3, 'the summary groups by currency and category'); const summaryGroup = summary.groups.find((row: any) => row.currency_code === 'THB'); assertOrThrow(summaryGroup, 'THB expenses form their own summary group'); assertEqualOrThrow(summaryGroup.category_id, rentId, 'the summary group names the category'); - assertEqualOrThrow(summaryGroup.category_name, 'Rent 2026', 'the summary labels a group with the current category name'); - assertEqualOrThrow(summaryGroup.expense_count, thbInCategory, 'the summary counts every matching expense, not just one page'); + assertEqualOrThrow( + summaryGroup.category_name, + 'Rent 2026', + 'the summary labels a group with the current category name', + ); + assertEqualOrThrow( + summaryGroup.expense_count, + thbInCategory, + 'the summary counts every matching expense, not just one page', + ); assertEqualOrThrow(summaryGroup.net_paid_minor, 350000, 'the summary reports current net paid'); - assertEqualOrThrow(summaryGroup.incurred_minor - summaryGroup.net_paid_minor, summaryGroup.due_minor, 'each group reconciles incurred = net paid + due'); + assertEqualOrThrow( + summaryGroup.incurred_minor - summaryGroup.net_paid_minor, + summaryGroup.due_minor, + 'each group reconciles incurred = net paid + due', + ); const summaryTotals = summary.totals.find((row: any) => row.currency_code === 'THB'); - assertEqualOrThrow(summaryTotals.incurred_minor, summaryGroup.incurred_minor, 'THB totals reconcile with the grouped amounts'); + assertEqualOrThrow( + summaryTotals.incurred_minor, + summaryGroup.incurred_minor, + 'THB totals reconcile with the grouped amounts', + ); assertEqualOrThrow(summary.totals.length, 3, 'the summary keeps each currency separate'); const otherCurrencyTotals = summary.totals.find((row: any) => row.currency_code !== 'THB'); - assertOrThrow(otherCurrencyTotals.expense_count > 0, 'expenses in another currency are grouped separately, never added together'); + assertOrThrow( + otherCurrencyTotals.expense_count > 0, + 'expenses in another currency are grouped separately, never added together', + ); // A payment recorded after the selected range still counts as paid-to-date. const boundsFrom = new Date(`${today}T00:00:00Z`); boundsFrom.setUTCDate(boundsFrom.getUTCDate() - 7); - const payLaterExpense = service.createExpense(db, createInput({ - incurred_on: boundsFrom.toISOString().slice(0, 10), - amount_minor: 90000, - idempotencyKey: 'exp-paid-later', - })).body.expense; - insertSeedPayment('seed-pay-later', 90000, 'other', `${today} 23:00:00`, { expenseId: payLaterExpense.id }); - const narrowRange = service.summarizeExpenses(db, { from: boundsFrom.toISOString().slice(0, 10), to: boundsFrom.toISOString().slice(0, 10), categoryId: rentId }); - assertEqualOrThrow(narrowRange.groups.length, 1, 'the narrow range covers only the expenses incurred on that day'); + const payLaterExpense = service.createExpense( + db, + createInput({ + incurred_on: boundsFrom.toISOString().slice(0, 10), + amount_minor: 90000, + idempotencyKey: 'exp-paid-later', + }), + ).body.expense; + insertSeedPayment('seed-pay-later', 90000, 'other', `${today} 23:00:00`, { + expenseId: payLaterExpense.id, + }); + const narrowRange = service.summarizeExpenses(db, { + from: boundsFrom.toISOString().slice(0, 10), + to: boundsFrom.toISOString().slice(0, 10), + categoryId: rentId, + }); + assertEqualOrThrow( + narrowRange.groups.length, + 1, + 'the narrow range covers only the expenses incurred on that day', + ); const narrowGroup = narrowRange.groups[0]; - assertEqualOrThrow(narrowGroup.expense_count, 1, 'the narrow range reconciles to the single in-range expense'); - assertEqualOrThrow(narrowGroup.net_paid_minor, 90000, 'a payment after the range still counts as paid to date'); - assertEqualOrThrow(narrowGroup.due_minor, narrowGroup.incurred_minor - 90000, 'the narrow-range due reconciles'); + assertEqualOrThrow( + narrowGroup.expense_count, + 1, + 'the narrow range reconciles to the single in-range expense', + ); + assertEqualOrThrow( + narrowGroup.net_paid_minor, + 90000, + 'a payment after the range still counts as paid to date', + ); + assertEqualOrThrow( + narrowGroup.due_minor, + narrowGroup.incurred_minor - 90000, + 'the narrow-range due reconciles', + ); assertEqualOrThrow( service.getExpense(db, payLaterExpense.id).paid_minor, 90000, 'the ledger records the payment against the original expense', ); - expectError(() => service.summarizeExpenses(db, { from: '2026-02-30' }), 400, 'the summary validates its range'); + expectError( + () => service.summarizeExpenses(db, { from: '2026-02-30' }), + 400, + 'the summary validates its range', + ); const emptyRange = service.summarizeExpenses(db, { from: '2000-01-01', to: '2000-01-02' }); - assertEqualOrThrow(emptyRange.groups.length, 0, 'a range with no expenses aggregates to no groups'); - assertEqualOrThrow(emptyRange.totals.length, 0, 'a range with no expenses aggregates to no totals'); + assertEqualOrThrow( + emptyRange.groups.length, + 0, + 'a range with no expenses aggregates to no groups', + ); + assertEqualOrThrow( + emptyRange.totals.length, + 0, + 'a range with no expenses aggregates to no totals', + ); // ── Void: reasoned, unpaid only, original financial fields preserved ── - const voidTarget = service.createExpense(db, createInput({ description: 'Duplicate invoice', amount_minor: 70000, idempotencyKey: 'exp-void-target' })).body.expense; - expectError(() => service.voidExpense(db, voidTarget.id, { actorUserId: actor, idempotencyKey: 'void-no-reason' }), 400, 'a void without a reason is rejected'); - expectError(() => service.voidExpense(db, voidTarget.id, { reason: ' '.repeat(3), actorUserId: actor, idempotencyKey: 'void-blank-reason' }), 400, 'a blank void reason is rejected'); - expectError(() => service.voidExpense(db, voidTarget.id, { reason: 'x'.repeat(501), actorUserId: actor, idempotencyKey: 'void-long-reason' }), 400, 'an over-long void reason is rejected'); - expectError(() => service.voidExpense(db, 'missing-expense', { reason: 'Nope', actorUserId: actor, idempotencyKey: 'void-missing' }), 404, 'voiding an unknown expense is a 404'); - expectError(() => service.voidExpense(db, rentExpenseId, { reason: 'Has payments', actorUserId: actor, idempotencyKey: 'void-paid' }), 409, 'an expense with unreversed payments cannot be voided', 'expense_has_payments'); - - const voided = service.voidExpense(db, voidTarget.id, { reason: 'Duplicate invoice', actorUserId: actor, idempotencyKey: 'void-target' }); + const voidTarget = service.createExpense( + db, + createInput({ + description: 'Duplicate invoice', + amount_minor: 70000, + idempotencyKey: 'exp-void-target', + }), + ).body.expense; + expectError( + () => + service.voidExpense(db, voidTarget.id, { + actorUserId: actor, + idempotencyKey: 'void-no-reason', + }), + 400, + 'a void without a reason is rejected', + ); + expectError( + () => + service.voidExpense(db, voidTarget.id, { + reason: ' '.repeat(3), + actorUserId: actor, + idempotencyKey: 'void-blank-reason', + }), + 400, + 'a blank void reason is rejected', + ); + expectError( + () => + service.voidExpense(db, voidTarget.id, { + reason: 'x'.repeat(501), + actorUserId: actor, + idempotencyKey: 'void-long-reason', + }), + 400, + 'an over-long void reason is rejected', + ); + expectError( + () => + service.voidExpense(db, 'missing-expense', { + reason: 'Nope', + actorUserId: actor, + idempotencyKey: 'void-missing', + }), + 404, + 'voiding an unknown expense is a 404', + ); + expectError( + () => + service.voidExpense(db, rentExpenseId, { + reason: 'Has payments', + actorUserId: actor, + idempotencyKey: 'void-paid', + }), + 409, + 'an expense with unreversed payments cannot be voided', + 'expense_has_payments', + ); + + const voided = service.voidExpense(db, voidTarget.id, { + reason: 'Duplicate invoice', + actorUserId: actor, + idempotencyKey: 'void-target', + }); assertEqualOrThrow(voided.status, 200, 'void returns the expense'); assertEqualOrThrow(voided.body.expense.status, 'voided', 'void marks the expense voided'); - assertEqualOrThrow(voided.body.expense.void_reason, 'Duplicate invoice', 'void stores the reason'); - assertEqualOrThrow(voided.body.expense.voided_by, actor, 'void attributes the authenticated actor'); + assertEqualOrThrow( + voided.body.expense.void_reason, + 'Duplicate invoice', + 'void stores the reason', + ); + assertEqualOrThrow( + voided.body.expense.voided_by, + actor, + 'void attributes the authenticated actor', + ); assertEqualOrThrow(voided.body.expense.amount_minor, 70000, 'void preserves the original amount'); - assertEqualOrThrow(voided.body.expense.created_by, voidTarget.created_by, 'void preserves the original creator and timestamp'); + assertEqualOrThrow( + voided.body.expense.created_by, + voidTarget.created_by, + 'void preserves the original creator and timestamp', + ); assertEqualOrThrow(voided.body.expense.paid_minor, 0, 'void leaves the payment ledger untouched'); - const voidReplay = service.voidExpense(db, voidTarget.id, { reason: 'Duplicate invoice', actorUserId: actor, idempotencyKey: 'void-target' }); + const voidReplay = service.voidExpense(db, voidTarget.id, { + reason: 'Duplicate invoice', + actorUserId: actor, + idempotencyKey: 'void-target', + }); assertEqualOrThrow(voidReplay.replayed, true, 'a replayed void returns the committed result'); - expectError(() => service.voidExpense(db, voidTarget.id, { reason: 'Different reason', actorUserId: actor, idempotencyKey: 'void-target' }), 409, 'a reused void key with a different reason conflicts', 'idempotency_conflict'); - expectError(() => service.voidExpense(db, voidTarget.id, { reason: 'Again', actorUserId: actor, idempotencyKey: 'void-twice' }), 409, 'an already voided expense cannot be voided again', 'expense_voided'); + expectError( + () => + service.voidExpense(db, voidTarget.id, { + reason: 'Different reason', + actorUserId: actor, + idempotencyKey: 'void-target', + }), + 409, + 'a reused void key with a different reason conflicts', + 'idempotency_conflict', + ); + expectError( + () => + service.voidExpense(db, voidTarget.id, { + reason: 'Again', + actorUserId: actor, + idempotencyKey: 'void-twice', + }), + 409, + 'an already voided expense cannot be voided again', + 'expense_voided', + ); assertOrThrow( - !service.listExpenses(db, { categoryId: rentId }).expenses.some((row: any) => row.id === voidTarget.id), + !service + .listExpenses(db, { categoryId: rentId }) + .expenses.some((row: any) => row.id === voidTarget.id), 'voided expenses drop out of the default list', ); assertOrThrow( - service.listExpenses(db, { categoryId: rentId, status: 'voided' }).expenses.some((row: any) => row.id === voidTarget.id), + service + .listExpenses(db, { categoryId: rentId, status: 'voided' }) + .expenses.some((row: any) => row.id === voidTarget.id), 'voided expenses stay readable through the history filter', ); - const activeThbAfterVoid = Number((db.prepare(`SELECT COUNT(*) AS count FROM expenses WHERE category_id = ? AND voided_at IS NULL AND currency_code = 'THB'`).get(rentId) as { count: number }).count); - const afterVoidThbGroup = service.summarizeExpenses(db, { from: historicalDate, to: rangeTo, categoryId: rentId }).groups.find((row: any) => row.currency_code === 'THB'); - assertEqualOrThrow(afterVoidThbGroup.expense_count, activeThbAfterVoid, 'voided expenses leave the headline totals'); + const activeThbAfterVoid = Number( + ( + db + .prepare( + `SELECT COUNT(*) AS count FROM expenses WHERE category_id = ? AND voided_at IS NULL AND currency_code = 'THB'`, + ) + .get(rentId) as { count: number } + ).count, + ); + const afterVoidThbGroup = service + .summarizeExpenses(db, { from: historicalDate, to: rangeTo, categoryId: rentId }) + .groups.find((row: any) => row.currency_code === 'THB'); + assertEqualOrThrow( + afterVoidThbGroup.expense_count, + activeThbAfterVoid, + 'voided expenses leave the headline totals', + ); // ── Replace: one atomic void-and-create, linked both ways ── - const replaceSource = service.createExpense(db, createInput({ description: 'Wrong amount', amount_minor: 50000, idempotencyKey: 'exp-replace-source' })).body.expense; + const replaceSource = service.createExpense( + db, + createInput({ + description: 'Wrong amount', + amount_minor: 50000, + idempotencyKey: 'exp-replace-source', + }), + ).body.expense; const replaceFields = { category_id: rentId, description: 'Corrected amount', @@ -700,8 +1587,28 @@ function runServiceChecks(db: any): void { notes: 'Correction', reason: 'Original amount was wrong', }; - expectError(() => service.replaceExpense(db, replaceSource.id, { ...replaceFields, reason: ' '.repeat(2), actorUserId: actor, idempotencyKey: 'replace-no-reason' }), 400, 'a replacement without a reason is rejected'); - expectError(() => service.replaceExpense(db, rentExpenseId, { ...replaceFields, actorUserId: actor, idempotencyKey: 'replace-paid' }), 409, 'an expense with unreversed payments cannot be replaced', 'expense_has_payments'); + expectError( + () => + service.replaceExpense(db, replaceSource.id, { + ...replaceFields, + reason: ' '.repeat(2), + actorUserId: actor, + idempotencyKey: 'replace-no-reason', + }), + 400, + 'a replacement without a reason is rejected', + ); + expectError( + () => + service.replaceExpense(db, rentExpenseId, { + ...replaceFields, + actorUserId: actor, + idempotencyKey: 'replace-paid', + }), + 409, + 'an expense with unreversed payments cannot be replaced', + 'expense_has_payments', + ); const replaced = service.replaceExpense(db, replaceSource.id, { ...replaceFields, @@ -714,47 +1621,165 @@ function runServiceChecks(db: any): void { idempotencyKey: 'replace-source', }); assertEqualOrThrow(replaced.status, 201, 'replace returns the new expense'); - assertEqualOrThrow(replaced.body.replaced_expense_id, replaceSource.id, 'replace names the voided source'); - assertEqualOrThrow(replaced.body.expense.replaces_expense_id, replaceSource.id, 'the replacement links back to its source'); - assertEqualOrThrow(replaced.body.expense.amount_minor, 75000, 'the replacement carries the corrected fields'); - assertEqualOrThrow(replaced.body.expense.created_by, actor, 'the replacement attributes the authenticated actor'); + assertEqualOrThrow( + replaced.body.replaced_expense_id, + replaceSource.id, + 'replace names the voided source', + ); + assertEqualOrThrow( + replaced.body.expense.replaces_expense_id, + replaceSource.id, + 'the replacement links back to its source', + ); + assertEqualOrThrow( + replaced.body.expense.amount_minor, + 75000, + 'the replacement carries the corrected fields', + ); + assertEqualOrThrow( + replaced.body.expense.created_by, + actor, + 'the replacement attributes the authenticated actor', + ); const voidedSource = service.getExpense(db, replaceSource.id); assertEqualOrThrow(voidedSource.status, 'replaced', 'the source is reported as replaced'); - assertEqualOrThrow(voidedSource.void_reason, 'Original amount was wrong', 'the source records the replacement reason'); + assertEqualOrThrow( + voidedSource.void_reason, + 'Original amount was wrong', + 'the source records the replacement reason', + ); assertEqualOrThrow(voidedSource.voided_by, actor, 'the source records who replaced it'); assertEqualOrThrow(voidedSource.amount_minor, 50000, 'the source keeps its original amount'); - assertEqualOrThrow(voidedSource.category_name, replaceSource.category_name, 'the source keeps its original category label'); + assertEqualOrThrow( + voidedSource.category_name, + replaceSource.category_name, + 'the source keeps its original category label', + ); assertOrThrow( - !service.listExpenses(db, { categoryId: rentId, status: 'voided' }).expenses.some((row: any) => row.id === replaceSource.id), + !service + .listExpenses(db, { categoryId: rentId, status: 'voided' }) + .expenses.some((row: any) => row.id === replaceSource.id), 'a replaced source is excluded from status=voided', ); assertOrThrow( - service.listExpenses(db, { categoryId: rentId, status: 'replaced' }).expenses.some((row: any) => row.id === replaceSource.id), + service + .listExpenses(db, { categoryId: rentId, status: 'replaced' }) + .expenses.some((row: any) => row.id === replaceSource.id), 'a replaced source is present in status=replaced', ); assertEqualOrThrow( - Number((db.prepare(`SELECT COUNT(*) AS count FROM expense_mutations WHERE idempotency_key = 'replace-source'`).get() as { count: number }).count), + Number( + ( + db + .prepare( + `SELECT COUNT(*) AS count FROM expense_mutations WHERE idempotency_key = 'replace-source'`, + ) + .get() as { count: number } + ).count, + ), 1, 'a replacement writes exactly one receipt', ); - const replaceReplay = service.replaceExpense(db, replaceSource.id, { ...replaceFields, actorUserId: actor, idempotencyKey: 'replace-source' }); - assertEqualOrThrow(replaceReplay.replayed, true, 'a replayed replacement returns the committed result'); - assertEqualOrThrow(replaceReplay.body.expense.id, replaced.body.expense.id, 'a replayed replacement never creates a second record'); + const replaceReplay = service.replaceExpense(db, replaceSource.id, { + ...replaceFields, + actorUserId: actor, + idempotencyKey: 'replace-source', + }); + assertEqualOrThrow( + replaceReplay.replayed, + true, + 'a replayed replacement returns the committed result', + ); + assertEqualOrThrow( + replaceReplay.body.expense.id, + replaced.body.expense.id, + 'a replayed replacement never creates a second record', + ); setSetting('currency', 'JPY'); - const replaceReplayNoCurr = service.replaceExpense(db, replaceSource.id, { ...replaceFields, currency_code: undefined, actorUserId: actor, idempotencyKey: 'replace-source' }); - assertEqualOrThrow(replaceReplayNoCurr.replayed, true, 'replaying replacement with omitted currency survives a store currency change'); - assertEqualOrThrow(replaceReplayNoCurr.body.expense.id, replaced.body.expense.id, 'replacement replay returns the committed replacement id'); - expectError(() => service.replaceExpense(db, replaceSource.id, { ...replaceFields, currency_code: 'JPY', actorUserId: actor, idempotencyKey: 'replace-source' }), 409, 'changing the explicit replacement currency conflicts', 'idempotency_conflict'); + const replaceReplayNoCurr = service.replaceExpense(db, replaceSource.id, { + ...replaceFields, + currency_code: undefined, + actorUserId: actor, + idempotencyKey: 'replace-source', + }); + assertEqualOrThrow( + replaceReplayNoCurr.replayed, + true, + 'replaying replacement with omitted currency survives a store currency change', + ); + assertEqualOrThrow( + replaceReplayNoCurr.body.expense.id, + replaced.body.expense.id, + 'replacement replay returns the committed replacement id', + ); + expectError( + () => + service.replaceExpense(db, replaceSource.id, { + ...replaceFields, + currency_code: 'JPY', + actorUserId: actor, + idempotencyKey: 'replace-source', + }), + 409, + 'changing the explicit replacement currency conflicts', + 'idempotency_conflict', + ); setSetting('currency', 'THB'); - expectError(() => service.replaceExpense(db, replaceSource.id, { ...replaceFields, reason: 'Again', actorUserId: actor, idempotencyKey: 'replace-again' }), 409, 'a replaced source cannot be replaced twice', 'expense_replaced'); - expectError(() => service.replaceExpense(db, voidTarget.id, { ...replaceFields, reason: 'Voided', actorUserId: actor, idempotencyKey: 'replace-voided' }), 409, 'a voided expense cannot be replaced', 'expense_voided'); - expectError(() => service.replaceExpense(db, 'missing-expense', { ...replaceFields, actorUserId: actor, idempotencyKey: 'replace-missing' }), 404, 'replacing an unknown expense is a 404'); + expectError( + () => + service.replaceExpense(db, replaceSource.id, { + ...replaceFields, + reason: 'Again', + actorUserId: actor, + idempotencyKey: 'replace-again', + }), + 409, + 'a replaced source cannot be replaced twice', + 'expense_replaced', + ); + expectError( + () => + service.replaceExpense(db, voidTarget.id, { + ...replaceFields, + reason: 'Voided', + actorUserId: actor, + idempotencyKey: 'replace-voided', + }), + 409, + 'a voided expense cannot be replaced', + 'expense_voided', + ); + expectError( + () => + service.replaceExpense(db, 'missing-expense', { + ...replaceFields, + actorUserId: actor, + idempotencyKey: 'replace-missing', + }), + 404, + 'replacing an unknown expense is a 404', + ); // A failed replacement must roll back the whole pair: no new record, source still active. - const rollbackSource = service.createExpense(db, createInput({ description: 'Rollback source', amount_minor: 10000, idempotencyKey: 'exp-rollback-source' })).body.expense; - const expenseCountBeforeRollback = Number((db.prepare('SELECT COUNT(*) AS count FROM expenses').get() as { count: number }).count); + const rollbackSource = service.createExpense( + db, + createInput({ + description: 'Rollback source', + amount_minor: 10000, + idempotencyKey: 'exp-rollback-source', + }), + ).body.expense; + const expenseCountBeforeRollback = Number( + (db.prepare('SELECT COUNT(*) AS count FROM expenses').get() as { count: number }).count, + ); expectError( - () => service.replaceExpense(db, rollbackSource.id, { ...replaceFields, amount_minor: 0, actorUserId: actor, idempotencyKey: 'replace-rollback' }), + () => + service.replaceExpense(db, rollbackSource.id, { + ...replaceFields, + amount_minor: 0, + actorUserId: actor, + idempotencyKey: 'replace-rollback', + }), 400, 'a replacement with an invalid amount is rejected', ); @@ -763,26 +1788,481 @@ function runServiceChecks(db: any): void { expenseCountBeforeRollback, 'a failed replacement rolls back the new record', ); - assertEqualOrThrow(service.getExpense(db, rollbackSource.id).status, 'active', 'a failed replacement leaves its source active'); assertEqualOrThrow( - Number((db.prepare(`SELECT COUNT(*) AS count FROM expense_mutations WHERE idempotency_key = 'replace-rollback'`).get() as { count: number }).count), + service.getExpense(db, rollbackSource.id).status, + 'active', + 'a failed replacement leaves its source active', + ); + assertEqualOrThrow( + Number( + ( + db + .prepare( + `SELECT COUNT(*) AS count FROM expense_mutations WHERE idempotency_key = 'replace-rollback'`, + ) + .get() as { count: number } + ).count, + ), 0, 'a rolled-back replacement records no receipt', ); - const omittedReplaceInput = { ...replaceFields, currency_code: undefined, actorUserId: actor, idempotencyKey: 'replace-omitted' }; + const omittedReplaceInput = { + ...replaceFields, + currency_code: undefined, + actorUserId: actor, + idempotencyKey: 'replace-omitted', + }; const omittedReplacement = service.replaceExpense(db, rollbackSource.id, omittedReplaceInput); const rowsBeforeReplay = Number(db.prepare('SELECT COUNT(*) AS count FROM expenses').get().count); setSetting('currency', 'JPY'); closeDatabase(); initDatabase(); const reopenedDb = getDatabase(); - const replacementAfterReopen = service.replaceExpense(reopenedDb, rollbackSource.id, omittedReplaceInput); - assertEqualOrThrow(replacementAfterReopen.replayed, true, 'an omitted-currency replacement replays after reopening the database and changing currency'); - assertEqualOrThrow(replacementAfterReopen.body.expense.id, omittedReplacement.body.expense.id, 'persisted replacement replay returns its original id'); - assertEqualOrThrow(service.createExpense(reopenedDb, omittedInput).body.expense.id, omittedCreated.body.expense.id, 'persisted create replay returns its original id'); - assertEqualOrThrow(Number(reopenedDb.prepare('SELECT COUNT(*) AS count FROM expenses').get().count), rowsBeforeReplay, 'persisted replays add no expense records'); + const replacementAfterReopen = service.replaceExpense( + reopenedDb, + rollbackSource.id, + omittedReplaceInput, + ); + assertEqualOrThrow( + replacementAfterReopen.replayed, + true, + 'an omitted-currency replacement replays after reopening the database and changing currency', + ); + assertEqualOrThrow( + replacementAfterReopen.body.expense.id, + omittedReplacement.body.expense.id, + 'persisted replacement replay returns its original id', + ); + assertEqualOrThrow( + service.createExpense(reopenedDb, omittedInput).body.expense.id, + omittedCreated.body.expense.id, + 'persisted create replay returns its original id', + ); + assertEqualOrThrow( + Number(reopenedDb.prepare('SELECT COUNT(*) AS count FROM expenses').get().count), + rowsBeforeReplay, + 'persisted replays add no expense records', + ); + + // ── Aggregate safe-integer bounds, BigInt precision, and legacy recovery ── + const setReopenedSetting = (key: string, value: string) => + reopenedDb + .prepare('INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES (?, ?, ?)') + .run(key, value, now()); + const boundsCat1 = service.createExpenseCategory(reopenedDb, { + name: 'Aggregate Bounds Primary', + actorUserId: actor, + idempotencyKey: 'cat-bounds-1', + }).body.category.id; + const boundsCat2 = service.createExpenseCategory(reopenedDb, { + name: 'Aggregate Bounds Secondary', + actorUserId: actor, + idempotencyKey: 'cat-bounds-2', + }).body.category.id; + + // Clean isolated currency for exact boundary math (KWD currently has 1500 active from earlier currency snapshot test) + setReopenedSetting('currency', 'BHD'); + const headRoom = 1000; + const nearMaxAmount = Number.MAX_SAFE_INTEGER - headRoom; + const nearMaxCreated = service.createExpense(reopenedDb, { + category_id: boundsCat1, + description: 'Near max safe integer expense', + amount_minor: nearMaxAmount, + incurred_on: historicalDate, + actorUserId: actor, + idempotencyKey: 'exp-bhd-near-max', + }); + assertEqualOrThrow( + nearMaxCreated.status, + 201, + 'creating an expense near Number.MAX_SAFE_INTEGER succeeds', + ); + + const boundaryFill = service.createExpense(reopenedDb, { + category_id: boundsCat2, + description: 'Fill to exact Number.MAX_SAFE_INTEGER', + amount_minor: headRoom, + incurred_on: today, + actorUserId: actor, + idempotencyKey: 'exp-bhd-fill-max', + }); + assertEqualOrThrow( + boundaryFill.status, + 201, + 'creating an expense that brings the active currency total to Number.MAX_SAFE_INTEGER succeeds', + ); + const bhdBoundarySummary = service.summarizeExpenses(reopenedDb, { currencyCode: 'BHD' }); + assertEqualOrThrow(bhdBoundarySummary.totals.length, 1, 'BHD summary has one currency total row'); + assertEqualOrThrow( + bhdBoundarySummary.totals[0].incurred_minor, + Number.MAX_SAFE_INTEGER, + 'BHD summary total reaches exact Number.MAX_SAFE_INTEGER', + ); + assertEqualOrThrow( + bhdBoundarySummary.totals[0].due_minor, + Number.MAX_SAFE_INTEGER, + 'BHD summary due reaches exact Number.MAX_SAFE_INTEGER', + ); + assertEqualOrThrow( + bhdBoundarySummary.groups.length, + 2, + 'BHD summary groups by category at the max-safe boundary', + ); + + // Filtered summaries work on subsets of the max-safe currency + const bhdFilteredByCat = service.summarizeExpenses(reopenedDb, { + currencyCode: 'BHD', + categoryId: boundsCat1, + }); + assertEqualOrThrow( + bhdFilteredByCat.totals[0].incurred_minor, + nearMaxAmount, + 'category-filtered summary reports exact subset total', + ); + const bhdFilteredByDate = service.summarizeExpenses(reopenedDb, { + currencyCode: 'BHD', + from: today, + to: today, + }); + assertEqualOrThrow( + bhdFilteredByDate.totals[0].incurred_minor, + headRoom, + 'date-filtered summary reports exact date subset total', + ); + + // Idempotent replay of create at the boundary succeeds without re-triggering the bound guard + const boundaryCreateReplay = service.createExpense(reopenedDb, { + category_id: boundsCat2, + description: 'Fill to exact Number.MAX_SAFE_INTEGER', + amount_minor: headRoom, + incurred_on: today, + actorUserId: actor, + idempotencyKey: 'exp-bhd-fill-max', + }); + assertEqualOrThrow( + boundaryCreateReplay.replayed, + true, + 'create at the max-safe boundary replays idempotently', + ); + assertEqualOrThrow( + boundaryCreateReplay.body.expense.id, + boundaryFill.body.expense.id, + 'boundary create replay returns original expense id', + ); + + // Adding 100 (or 1 or 2) past Number.MAX_SAFE_INTEGER is rejected atomically with 422 + const bhdCountBeforeOverflow = Number( + ( + reopenedDb + .prepare(`SELECT COUNT(*) AS count FROM expenses WHERE currency_code = 'BHD'`) + .get() as { count: number } + ).count, + ); + for (const extra of [100, 1, 2]) { + const key = `exp-bhd-over-${extra}`; + expectError( + () => + service.createExpense(reopenedDb, { + category_id: boundsCat1, + description: `Over boundary by ${extra}`, + amount_minor: extra, + incurred_on: today, + actorUserId: actor, + idempotencyKey: key, + }), + 422, + `adding ${extra} beyond Number.MAX_SAFE_INTEGER is rejected with 422`, + 'amount_range_exceeded', + ); + assertEqualOrThrow( + Number( + ( + reopenedDb + .prepare('SELECT COUNT(*) AS count FROM expense_mutations WHERE idempotency_key = ?') + .get(key) as { count: number } + ).count, + ), + 0, + `rejected +${extra} create writes no idempotency receipt`, + ); + } + assertEqualOrThrow( + Number( + ( + reopenedDb + .prepare(`SELECT COUNT(*) AS count FROM expenses WHERE currency_code = 'BHD'`) + .get() as { count: number } + ).count, + ), + bhdCountBeforeOverflow, + 'rejected over-boundary creates leave no expense rows behind', + ); + + // Per-currency isolation: another currency can still be created and summarized up to Number.MAX_SAFE_INTEGER + setReopenedSetting('currency', 'OMR'); + const omrMax = service.createExpense(reopenedDb, { + category_id: boundsCat1, + description: 'OMR at max safe integer', + amount_minor: Number.MAX_SAFE_INTEGER, + incurred_on: today, + actorUserId: actor, + idempotencyKey: 'exp-omr-max', + }); + assertEqualOrThrow( + omrMax.status, + 201, + 'a second currency can independently reach Number.MAX_SAFE_INTEGER', + ); + const multiCurrencySummary = service.summarizeExpenses(reopenedDb); + assertEqualOrThrow( + multiCurrencySummary.totals.find((row: any) => row.currency_code === 'BHD')?.incurred_minor, + Number.MAX_SAFE_INTEGER, + 'unfiltered summary preserves BHD max-safe total alongside OMR', + ); + assertEqualOrThrow( + multiCurrencySummary.totals.find((row: any) => row.currency_code === 'OMR')?.incurred_minor, + Number.MAX_SAFE_INTEGER, + 'unfiltered summary preserves OMR max-safe total alongside BHD', + ); + + // Replacement subtracts the replaced active expense's amount before testing the new total + setReopenedSetting('currency', 'BHD'); + expectError( + () => + service.replaceExpense(reopenedDb, boundaryFill.body.expense.id, { + category_id: boundsCat2, + description: 'Replace pushing 100 past max', + amount_minor: headRoom + 100, + incurred_on: today, + reason: 'Attempted increase past boundary', + actorUserId: actor, + idempotencyKey: 'replace-bhd-over-100', + }), + 422, + 'replacement that increases active total by 100 past Number.MAX_SAFE_INTEGER is rejected with 422', + 'amount_range_exceeded', + ); + assertEqualOrThrow( + service.getExpense(reopenedDb, boundaryFill.body.expense.id).status, + 'active', + 'rejected over-boundary replacement leaves source expense active', + ); + assertEqualOrThrow( + Number( + ( + reopenedDb + .prepare( + `SELECT COUNT(*) AS count FROM expense_mutations WHERE idempotency_key = 'replace-bhd-over-100'`, + ) + .get() as { count: number } + ).count, + ), + 0, + 'rejected over-boundary replacement writes no receipt', + ); + + const boundaryReplaced = service.replaceExpense(reopenedDb, boundaryFill.body.expense.id, { + category_id: boundsCat2, + description: 'Replace staying at exact max', + amount_minor: headRoom, + incurred_on: today, + reason: 'Corrected description at boundary', + actorUserId: actor, + idempotencyKey: 'replace-bhd-exact-max', + }); + assertEqualOrThrow( + boundaryReplaced.status, + 201, + 'replacement that subtracts old amount and stays at Number.MAX_SAFE_INTEGER succeeds', + ); + assertEqualOrThrow( + service.summarizeExpenses(reopenedDb, { currencyCode: 'BHD' }).totals[0].incurred_minor, + Number.MAX_SAFE_INTEGER, + 'active currency total remains exact Number.MAX_SAFE_INTEGER after replacement', + ); + const boundaryReplaceReplay = service.replaceExpense(reopenedDb, boundaryFill.body.expense.id, { + category_id: boundsCat2, + description: 'Replace staying at exact max', + amount_minor: headRoom, + incurred_on: today, + reason: 'Corrected description at boundary', + actorUserId: actor, + idempotencyKey: 'replace-bhd-exact-max', + }); + assertEqualOrThrow( + boundaryReplaceReplay.replayed, + true, + 'replacement at the max-safe boundary replays idempotently', + ); + assertEqualOrThrow( + boundaryReplaceReplay.body.expense.id, + boundaryReplaced.body.expense.id, + 'boundary replacement replay returns original replacement id', + ); + + // Legacy oversized data (> Number.MAX_SAFE_INTEGER and SQLite int64 overflow) returns 422 while keeping recovery working + const legacyOverflowRowId = 'exp_legacy_bhd_overflow_100'; + reopenedDb + .prepare(` + INSERT INTO expenses ( + id, category_id, category_name, description, amount_minor, currency_code, incurred_on, created_by, created_at + ) VALUES (?, ?, 'Aggregate Bounds Secondary', 'Legacy oversized row', 100, 'BHD', ?, ?, ?) + `) + .run(legacyOverflowRowId, boundsCat2, today, actor, now()); + + expectError( + () => service.summarizeExpenses(reopenedDb, { currencyCode: 'BHD' }), + 422, + 'legacy oversized aggregate (> Number.MAX_SAFE_INTEGER) fails with controlled 422 instead of 500', + 'amount_range_exceeded', + ); + // Filtered summary that excludes the oversized combination still works + assertEqualOrThrow( + service.summarizeExpenses(reopenedDb, { currencyCode: 'BHD', categoryId: boundsCat1 }).totals[0] + .incurred_minor, + nearMaxAmount, + 'filtered summary excluding the extra category still succeeds while overall currency summary is out of range', + ); + // List and detail remain usable during oversized summary state + assertOrThrow( + service + .listExpenses(reopenedDb, { currencyCode: 'BHD' }) + .expenses.some((row: any) => row.id === legacyOverflowRowId), + 'listExpenses remains usable when the currency summary is out of range', + ); + assertEqualOrThrow( + service.getExpense(reopenedDb, legacyOverflowRowId).amount_minor, + 100, + 'getExpense remains usable when the currency summary is out of range', + ); + // Void recovery brings the summary back into range + const recoveredByVoid = service.voidExpense(reopenedDb, legacyOverflowRowId, { + reason: 'Void legacy overflow row', + actorUserId: actor, + idempotencyKey: 'void-legacy-bhd-overflow', + }); + assertEqualOrThrow( + recoveredByVoid.status, + 200, + 'voiding an expense succeeds while summary is out of range', + ); + assertEqualOrThrow( + service.summarizeExpenses(reopenedDb, { currencyCode: 'BHD' }).totals[0].incurred_minor, + Number.MAX_SAFE_INTEGER, + 'voiding the extra legacy expense restores the summary to Number.MAX_SAFE_INTEGER', + ); + + // Replace recovery when legacy data exceeds Number.MAX_SAFE_INTEGER (and when SQLite SUM overflows int64) + const legacyOverflowRow2Id = 'exp_legacy_bhd_overflow_200'; + reopenedDb + .prepare(` + INSERT INTO expenses ( + id, category_id, category_name, description, amount_minor, currency_code, incurred_on, created_by, created_at + ) VALUES (?, ?, 'Aggregate Bounds Secondary', 'Legacy oversized row 2', 200, 'BHD', ?, ?, ?) + `) + .run(legacyOverflowRow2Id, boundsCat2, today, actor, now()); + expectError( + () => service.summarizeExpenses(reopenedDb, { currencyCode: 'BHD' }), + 422, + 'second legacy oversized aggregate returns 422', + 'amount_range_exceeded', + ); + const recoveredByReplace = service.replaceExpense(reopenedDb, nearMaxCreated.body.expense.id, { + category_id: boundsCat1, + description: 'Reduced near-max expense to recover summary', + amount_minor: nearMaxAmount - 200, + incurred_on: historicalDate, + reason: 'Reduce legacy oversized aggregate via replacement', + actorUserId: actor, + idempotencyKey: 'replace-legacy-bhd-recover', + }); + assertEqualOrThrow( + recoveredByReplace.status, + 201, + 'replacing an active expense with a smaller amount succeeds when summary was out of range', + ); + assertEqualOrThrow( + service.summarizeExpenses(reopenedDb, { currencyCode: 'BHD' }).totals[0].incurred_minor, + Number.MAX_SAFE_INTEGER, + 'replacement recovery restores the summary to exact Number.MAX_SAFE_INTEGER', + ); + + // SQLite 64-bit signed integer overflow in SUM() (e.g. INT64_MAX + existing active rows) maps to 422, not 500 + const sqliteInt64Max = BigInt('9223372036854775807'); + const int64OverflowRowId = 'exp_legacy_omr_int64_max'; + reopenedDb + .prepare(` + INSERT INTO expenses ( + id, category_id, category_name, description, amount_minor, currency_code, incurred_on, created_by, created_at + ) VALUES (?, ?, 'Aggregate Bounds Primary', 'SQLite int64 max row', ?, 'OMR', ?, ?, ?) + `) + .run(int64OverflowRowId, boundsCat1, sqliteInt64Max, today, actor, now()); + expectError( + () => service.summarizeExpenses(reopenedDb, { currencyCode: 'OMR' }), + 422, + 'SQLite 64-bit integer overflow in summarizeExpenses maps to controlled 422', + 'amount_range_exceeded', + ); + setReopenedSetting('currency', 'OMR'); + expectError( + () => + service.createExpense(reopenedDb, { + category_id: boundsCat1, + description: 'Blocked by int64 overflow', + amount_minor: 1, + incurred_on: today, + actorUserId: actor, + idempotencyKey: 'exp-omr-int64-blocked', + }), + 422, + 'SQLite 64-bit integer overflow during createExpense maps to controlled 422', + 'amount_range_exceeded', + ); + expectError( + () => + service.replaceExpense(reopenedDb, int64OverflowRowId, { + category_id: boundsCat1, + description: 'Replaced int64 max row while OMR still has Number.MAX_SAFE_INTEGER', + amount_minor: 500, + incurred_on: today, + reason: 'Attempt replace while OMR still has Number.MAX_SAFE_INTEGER active', + actorUserId: actor, + idempotencyKey: 'replace-omr-still-too-high', + }), + 422, + 'replacing int64 overflow row is still rejected with 422 when remaining active total + replacement exceeds max safe integer', + 'amount_range_exceeded', + ); + service.voidExpense(reopenedDb, omrMax.body.expense.id, { + reason: 'Clear max-safe row before replacing int64 overflow row', + actorUserId: actor, + idempotencyKey: 'void-omr-max-before-int64-replace', + }); + const recoveredInt64ByReplace = service.replaceExpense(reopenedDb, int64OverflowRowId, { + category_id: boundsCat1, + description: 'Replaced int64 max row into supported range', + amount_minor: 500, + incurred_on: today, + reason: 'Recover SQLite int64 overflow row into safe range', + actorUserId: actor, + idempotencyKey: 'replace-omr-int64-recover', + }); + assertEqualOrThrow( + recoveredInt64ByReplace.status, + 201, + 'replacing the SQLite int64 overflow row into safe range succeeds', + ); + assertEqualOrThrow( + service.summarizeExpenses(reopenedDb, { currencyCode: 'OMR' }).totals[0].incurred_minor, + 500, + 'OMR summary succeeds with exact replacement amount after recovering from int64 overflow', + ); // ── Expense workflows leave sales, stock, and drawer rows untouched ── - assertEqualOrThrow(JSON.stringify(countRows(reopenedDb)), JSON.stringify(ledgerBefore), 'recording expenses never changes sales, stock, or drawer tables'); + assertEqualOrThrow( + JSON.stringify(countRows(reopenedDb)), + JSON.stringify(ledgerBefore), + 'recording expenses never changes sales, stock, or drawer tables', + ); } From ef377ddf4fa741199cfd21e82917821db2f5a793 Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Wed, 14 Oct 2026 20:41:42 -0400 Subject: [PATCH 2/7] no-mistakes(document): docs(expenses): document safe-integer aggregate bounds and 422 response --- docs/reference/api.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/reference/api.md b/docs/reference/api.md index 984735b47..cbfc4c2b9 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -371,8 +371,10 @@ Router: `main/routes/expenses.ts`. Full path: `/api/expenses`. Reads need `expen creation, voiding, and replacement need `expenses.manage` **and** `expenses.view`, while payment and reversal mutations need `expenses.pay` and `expenses.reverse` (alongside `expenses.view`, plus `cash.movements.manage` for cash methods) so an actor can always read back the row they touched. -Amounts are integer minor units in the expense's own `currency_code`; no endpoint converts currencies -or assumes a minor-unit factor. +Amounts are positive safe-integer minor units in the expense's own `currency_code`; no endpoint +converts currencies or assumes a minor-unit factor. Active incurred totals per currency (after +subtracting the replaced source on replacement) and returned aggregate totals must fit within +`Number.MAX_SAFE_INTEGER`, or the operation fails with `422 amount_range_exceeded`. | Method | Path | Authorization | Parameters | Response | | --- | --- | --- | --- | --- | From e9f95cedf7f77dbc4fa197e4e97e04e88d59aea8 Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Wed, 14 Oct 2026 21:24:52 -0400 Subject: [PATCH 3/7] fix(expenses): allow voiding oversized legacy expense rows --- main/services/expenses.ts | 25 ++++++-- tests/expense-api.test.ts | 42 +++++++++++++ tests/expense-records.test.ts | 111 ++++++++++++++++++++++++++++++++++ 3 files changed, 172 insertions(+), 6 deletions(-) diff --git a/main/services/expenses.ts b/main/services/expenses.ts index 069dd7c26..41261ba13 100644 --- a/main/services/expenses.ts +++ b/main/services/expenses.ts @@ -76,7 +76,7 @@ export interface ExpenseRecord { description: string; payee: string | null; notes: string | null; - amount_minor: number; + amount_minor: number | null; currency_code: string; incurred_on: string; created_by: string; @@ -87,7 +87,7 @@ export interface ExpenseRecord { void_reason: string | null; status: ExpenseStatus; paid_minor: number; - due_minor: number; + due_minor: number | null; } export interface ExpensePaymentRecord { @@ -306,9 +306,13 @@ function toBigIntTotal(value: bigint | number, label: string): bigint { return BigInt(value); } +function isSafeTotal(value: bigint): boolean { + return value >= MIN_SAFE_TOTAL && value <= MAX_SAFE_TOTAL; +} + function requireSafeTotal(value: bigint | number, label: string): number { const total = toBigIntTotal(value, label); - if (total < MIN_SAFE_TOTAL || total > MAX_SAFE_TOTAL) { + if (!isSafeTotal(total)) { throw unsupportedAmountRange(label); } return Number(total); @@ -505,8 +509,17 @@ interface RawExpenseRow extends Omit expense.due_minor) + if (expense.due_minor === null || amountMinor > expense.due_minor) throw conflict('Payment exceeds the amount due', 'expense_overpaid'); const businessDate = currentBusinessDate(); let cashMovementId: number | null = null; diff --git a/tests/expense-api.test.ts b/tests/expense-api.test.ts index 63d9f4d3b..462563c87 100644 --- a/tests/expense-api.test.ts +++ b/tests/expense-api.test.ts @@ -913,6 +913,48 @@ async function main(): Promise { 'HTTP summary reflects voided legacy row', ); + // Direct HTTP void of an oversized row whose own amount_minor exceeds Number.MAX_SAFE_INTEGER + const legacyOversizedSingleId = 'exp_legacy_http_single_int64_max'; + db.prepare(` + INSERT INTO expenses ( + id, category_id, category_name, description, amount_minor, currency_code, incurred_on, created_by, created_at + ) VALUES (?, ?, 'Utilities', 'Legacy single oversized row', ?, 'INR', ?, 'mgr-test-001', ?) + `).run(legacyOversizedSingleId, utilitiesCategoryId, BigInt('9223372036854775807'), today, now()); + + const voidOversizedSingleHttp = await request(app) + .post(`/api/expenses/${legacyOversizedSingleId}/void`) + .set(manager) + .set('Idempotency-Key', 'api-void-legacy-single-int64-max') + .send({ reason: 'Void corrupt oversized row' }); + assertEqualOrThrow( + voidOversizedSingleHttp.status, + 200, + 'HTTP void of an oversized row (> Number.MAX_SAFE_INTEGER) succeeds', + ); + assertEqualOrThrow( + voidOversizedSingleHttp.body.expense.status, + 'voided', + 'HTTP void marks the oversized row voided', + ); + assertEqualOrThrow( + voidOversizedSingleHttp.body.expense.amount_minor, + null, + 'HTTP void of oversized row returns null amount_minor instead of a rounded number', + ); + assertEqualOrThrow( + voidOversizedSingleHttp.body.expense.due_minor, + null, + 'HTTP void of oversized row returns null due_minor instead of a rounded number', + ); + const summaryAfterSingleOversizedVoid = await request(app) + .get('/api/expenses/summary') + .set(manager); + assertEqualOrThrow( + summaryAfterSingleOversizedVoid.status, + 200, + 'HTTP summary returns 200 after voiding single oversized row', + ); + const results = getResults(); console.log(`\nExpense API: ${results.passed}/${results.total} checks passed`); if (results.failed > 0) { diff --git a/tests/expense-records.test.ts b/tests/expense-records.test.ts index 34aa25409..5ae9d7e52 100644 --- a/tests/expense-records.test.ts +++ b/tests/expense-records.test.ts @@ -2258,6 +2258,117 @@ function runServiceChecks(db: any): void { 500, 'OMR summary succeeds with exact replacement amount after recovering from int64 overflow', ); + const replacedInt64Source = service.getExpense(reopenedDb, int64OverflowRowId); + assertEqualOrThrow( + replacedInt64Source.status, + 'replaced', + 'replaced oversized legacy row remains readable as replaced', + ); + assertEqualOrThrow( + replacedInt64Source.amount_minor, + null, + 'replaced oversized legacy row returns null amount_minor instead of a rounded number', + ); + assertEqualOrThrow( + replacedInt64Source.due_minor, + null, + 'replaced oversized legacy row returns null due_minor instead of a rounded number', + ); + + // Direct void of an oversized legacy row (> Number.MAX_SAFE_INTEGER) succeeds without rounding + const int64VoidRowId = 'exp_legacy_omr_int64_void_target'; + reopenedDb + .prepare(` + INSERT INTO expenses ( + id, category_id, category_name, description, amount_minor, currency_code, incurred_on, created_by, created_at + ) VALUES (?, ?, 'Aggregate Bounds Primary', 'Oversized legacy row to void', ?, 'OMR', ?, ?, ?) + `) + .run(int64VoidRowId, boundsCat1, sqliteInt64Max, today, actor, now()); + expectError( + () => service.summarizeExpenses(reopenedDb, { currencyCode: 'OMR' }), + 422, + 'active oversized legacy row causes OMR summary to fail with 422 before voiding', + 'amount_range_exceeded', + ); + const recoveredInt64ByVoid = service.voidExpense(reopenedDb, int64VoidRowId, { + reason: 'Void oversized legacy row directly', + actorUserId: actor, + idempotencyKey: 'void-omr-int64-direct', + }); + assertEqualOrThrow( + recoveredInt64ByVoid.status, + 200, + 'voiding an oversized legacy row directly succeeds with 200', + ); + assertEqualOrThrow( + recoveredInt64ByVoid.body.expense.status, + 'voided', + 'oversized legacy row is marked voided', + ); + assertEqualOrThrow( + recoveredInt64ByVoid.body.expense.void_reason, + 'Void oversized legacy row directly', + 'voided oversized row records void reason', + ); + assertEqualOrThrow( + recoveredInt64ByVoid.body.expense.voided_by, + actor, + 'voided oversized row records actor', + ); + assertEqualOrThrow( + recoveredInt64ByVoid.body.expense.amount_minor, + null, + 'voided oversized row returns null amount_minor instead of a precision-losing rounded number', + ); + assertEqualOrThrow( + recoveredInt64ByVoid.body.expense.due_minor, + null, + 'voided oversized row returns null due_minor instead of a precision-losing rounded number', + ); + assertEqualOrThrow( + recoveredInt64ByVoid.body.expense.paid_minor, + 0, + 'voided oversized row reports 0 paid_minor', + ); + const recoveredInt64VoidReplay = service.voidExpense(reopenedDb, int64VoidRowId, { + reason: 'Void oversized legacy row directly', + actorUserId: actor, + idempotencyKey: 'void-omr-int64-direct', + }); + assertEqualOrThrow( + recoveredInt64VoidReplay.replayed, + true, + 'voiding an oversized legacy row replays idempotently', + ); + assertEqualOrThrow( + recoveredInt64VoidReplay.body.expense.amount_minor, + null, + 'replayed void of oversized legacy row preserves null amount_minor without rounding', + ); + const persistedOversizedRow = reopenedDb + .prepare('SELECT amount_minor, voided_at FROM expenses WHERE id = ?') + .safeIntegers(true) + .get(int64VoidRowId) as { amount_minor: bigint; voided_at: string | null }; + assertEqualOrThrow( + persistedOversizedRow.amount_minor, + sqliteInt64Max, + 'voiding an oversized legacy row preserves the exact 64-bit integer in SQLite', + ); + assertOrThrow( + persistedOversizedRow.voided_at !== null, + 'voiding an oversized legacy row commits voided_at in SQLite', + ); + assertOrThrow( + service + .listExpenses(reopenedDb, { currencyCode: 'OMR', status: 'voided' }) + .expenses.some((row: any) => row.id === int64VoidRowId && row.amount_minor === null), + 'voided oversized row remains readable in status=voided history without rounding', + ); + assertEqualOrThrow( + service.summarizeExpenses(reopenedDb, { currencyCode: 'OMR' }).totals[0].incurred_minor, + 500, + 'OMR summary succeeds again after voiding the oversized legacy row directly', + ); // ── Expense workflows leave sales, stock, and drawer rows untouched ── assertEqualOrThrow( From eea94ba6f1a549b591ec5924c04ceacbc2edcac7 Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Wed, 14 Oct 2026 21:37:53 -0400 Subject: [PATCH 4/7] no-mistakes(review): {"summary": "fix(expenses): project oversized active expense amounts as null"} --- main/services/expenses.ts | 14 +++---- tests/expense-api.test.ts | 42 ++++++++++++++++++++- tests/expense-records.test.ts | 71 ++++++++++++++++++++++++++++++++++- 3 files changed, 117 insertions(+), 10 deletions(-) diff --git a/main/services/expenses.ts b/main/services/expenses.ts index 41261ba13..1eca106f9 100644 --- a/main/services/expenses.ts +++ b/main/services/expenses.ts @@ -511,15 +511,13 @@ function projectExpenseRow(row: RawExpenseRow): ExpenseRecord { const paidRaw = toBigIntTotal(row.net_paid_minor, 'Expense paid total'); const paidMinor = requireSafeTotal(paidRaw, 'Expense paid total'); const dueRaw = amountRaw - paidRaw; - const allowOversizedInactive = row.voided_at !== null; - const amountMinor = - allowOversizedInactive && !isSafeTotal(amountRaw) - ? null - : requireSafeTotal(amountRaw, 'Expense amount'); + const amountMinor = isSafeTotal(amountRaw) + ? requireSafeTotal(amountRaw, 'Expense amount') + : null; const dueMinor = - allowOversizedInactive && !isSafeTotal(dueRaw) - ? null - : requireSafeTotal(dueRaw, 'Expense due total'); + amountMinor !== null && isSafeTotal(dueRaw) + ? requireSafeTotal(dueRaw, 'Expense due total') + : null; return { id: row.id, category_id: row.category_id, diff --git a/tests/expense-api.test.ts b/tests/expense-api.test.ts index 462563c87..4dabbe4ac 100644 --- a/tests/expense-api.test.ts +++ b/tests/expense-api.test.ts @@ -913,13 +913,53 @@ async function main(): Promise { 'HTTP summary reflects voided legacy row', ); - // Direct HTTP void of an oversized row whose own amount_minor exceeds Number.MAX_SAFE_INTEGER const legacyOversizedSingleId = 'exp_legacy_http_single_int64_max'; + const legacyOversizedPaymentId = 'exppay_legacy_http_single_int64_max'; db.prepare(` INSERT INTO expenses ( id, category_id, category_name, description, amount_minor, currency_code, incurred_on, created_by, created_at ) VALUES (?, ?, 'Utilities', 'Legacy single oversized row', ?, 'INR', ?, 'mgr-test-001', ?) `).run(legacyOversizedSingleId, utilitiesCategoryId, BigInt('9223372036854775807'), today, now()); + db.prepare(` + INSERT INTO expense_payments ( + id, expense_id, amount_minor, method, business_date, reversal_of, reason, reference, cash_movement_id, created_by, created_at + ) VALUES (?, ?, 300, 'card', ?, NULL, NULL, 'HTTP-LEGACY-REF', NULL, 'mgr-test-001', ?) + `).run(legacyOversizedPaymentId, legacyOversizedSingleId, today, now()); + + const listWithActiveSingleOversized = await request(app).get('/api/expenses').set(manager); + assertEqualOrThrow( + listWithActiveSingleOversized.status, + 200, + 'HTTP list remains 200 when an active oversized row (> Number.MAX_SAFE_INTEGER) is on the page', + ); + const detailActiveSingleOversized = await request(app) + .get(`/api/expenses/${legacyOversizedSingleId}`) + .set(manager); + assertEqualOrThrow( + detailActiveSingleOversized.status, + 200, + 'HTTP detail remains 200 on an active oversized row (> Number.MAX_SAFE_INTEGER)', + ); + assertEqualOrThrow( + detailActiveSingleOversized.body.expense.amount_minor, + null, + 'HTTP detail on active oversized row returns null amount_minor', + ); + assertEqualOrThrow( + detailActiveSingleOversized.body.expense.due_minor, + null, + 'HTTP detail on active oversized row returns null due_minor', + ); + const reverseOversizedSingleHttp = await request(app) + .post(`/api/expenses/${legacyOversizedSingleId}/payments/${legacyOversizedPaymentId}/reverse`) + .set(manager) + .set('Idempotency-Key', 'api-reverse-legacy-single-int64-max') + .send({ reason: 'Reverse payment before voiding oversized row' }); + assertEqualOrThrow( + reverseOversizedSingleHttp.status, + 201, + 'HTTP payment reversal on an active oversized row succeeds', + ); const voidOversizedSingleHttp = await request(app) .post(`/api/expenses/${legacyOversizedSingleId}/void`) diff --git a/tests/expense-records.test.ts b/tests/expense-records.test.ts index 5ae9d7e52..acfa898d8 100644 --- a/tests/expense-records.test.ts +++ b/tests/expense-records.test.ts @@ -2275,8 +2275,8 @@ function runServiceChecks(db: any): void { 'replaced oversized legacy row returns null due_minor instead of a rounded number', ); - // Direct void of an oversized legacy row (> Number.MAX_SAFE_INTEGER) succeeds without rounding const int64VoidRowId = 'exp_legacy_omr_int64_void_target'; + const int64PaymentId = 'exppay_legacy_omr_int64_payment'; reopenedDb .prepare(` INSERT INTO expenses ( @@ -2284,12 +2284,81 @@ function runServiceChecks(db: any): void { ) VALUES (?, ?, 'Aggregate Bounds Primary', 'Oversized legacy row to void', ?, 'OMR', ?, ?, ?) `) .run(int64VoidRowId, boundsCat1, sqliteInt64Max, today, actor, now()); + reopenedDb + .prepare(` + INSERT INTO expense_payments ( + id, expense_id, amount_minor, method, business_date, reversal_of, reason, reference, cash_movement_id, created_by, created_at + ) VALUES (?, ?, 250, 'card', ?, NULL, NULL, 'LEGACY-REF', NULL, ?, ?) + `) + .run(int64PaymentId, int64VoidRowId, today, actor, now()); expectError( () => service.summarizeExpenses(reopenedDb, { currencyCode: 'OMR' }), 422, 'active oversized legacy row causes OMR summary to fail with 422 before voiding', 'amount_range_exceeded', ); + const activeOversizedFetched = service.getExpense(reopenedDb, int64VoidRowId); + assertEqualOrThrow( + activeOversizedFetched.status, + 'active', + 'active oversized legacy row remains readable via getExpense', + ); + assertEqualOrThrow( + activeOversizedFetched.amount_minor, + null, + 'active oversized legacy row projects null amount_minor instead of throwing 422', + ); + assertEqualOrThrow( + activeOversizedFetched.paid_minor, + 250, + 'active oversized legacy row projects paid_minor while active', + ); + assertEqualOrThrow( + activeOversizedFetched.due_minor, + null, + 'active oversized legacy row projects null due_minor while active', + ); + assertOrThrow( + service + .listExpenses(reopenedDb, { currencyCode: 'OMR', status: 'active' }) + .expenses.some( + (row: any) => + row.id === int64VoidRowId && row.amount_minor === null && row.due_minor === null, + ), + 'listExpenses remains usable when an active oversized legacy row is on the page', + ); + expectError( + () => + service.recordExpensePayment(reopenedDb, int64VoidRowId, { + amount_minor: 100, + method: 'card', + actorUserId: actor, + idempotencyKey: 'pay-omr-int64-active-rejected', + }), + 409, + 'new payment on an active oversized legacy row is rejected with 409 when due_minor is null', + 'expense_overpaid', + ); + const reversedOversizedPayment = service.reverseExpensePayment( + reopenedDb, + int64VoidRowId, + int64PaymentId, + { + reason: 'Reverse legacy payment before voiding oversized row', + actorUserId: actor, + idempotencyKey: 'reverse-omr-int64-legacy-payment', + }, + ); + assertEqualOrThrow( + reversedOversizedPayment.status, + 201, + 'reversing a payment on an active oversized legacy row succeeds', + ); + assertEqualOrThrow( + reversedOversizedPayment.body.expense.paid_minor, + 0, + 'reversing the payment brings paid_minor on the active oversized row to 0', + ); const recoveredInt64ByVoid = service.voidExpense(reopenedDb, int64VoidRowId, { reason: 'Void oversized legacy row directly', actorUserId: actor, From 2ec134814b8a699efce25b11b334ef3deb17728d Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Wed, 14 Oct 2026 21:59:35 -0400 Subject: [PATCH 5/7] no-mistakes(document): docs(expenses): clarify legacy out-of-range amount projection and format --- docs/reference/api.md | 4 +++- main/services/expenses.ts | 4 +--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/reference/api.md b/docs/reference/api.md index cbfc4c2b9..fdc6c3d7f 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -374,7 +374,9 @@ reversal mutations need `expenses.pay` and `expenses.reverse` (alongside `expens Amounts are positive safe-integer minor units in the expense's own `currency_code`; no endpoint converts currencies or assumes a minor-unit factor. Active incurred totals per currency (after subtracting the replaced source on replacement) and returned aggregate totals must fit within -`Number.MAX_SAFE_INTEGER`, or the operation fails with `422 amount_range_exceeded`. +`Number.MAX_SAFE_INTEGER`, or the operation fails with `422 amount_range_exceeded`; legacy rows with +an out-of-range stored amount project `amount_minor` and `due_minor` as `null` so list, detail, and +recovery mutations stay usable without rounding. | Method | Path | Authorization | Parameters | Response | | --- | --- | --- | --- | --- | diff --git a/main/services/expenses.ts b/main/services/expenses.ts index 1eca106f9..9a340502b 100644 --- a/main/services/expenses.ts +++ b/main/services/expenses.ts @@ -511,9 +511,7 @@ function projectExpenseRow(row: RawExpenseRow): ExpenseRecord { const paidRaw = toBigIntTotal(row.net_paid_minor, 'Expense paid total'); const paidMinor = requireSafeTotal(paidRaw, 'Expense paid total'); const dueRaw = amountRaw - paidRaw; - const amountMinor = isSafeTotal(amountRaw) - ? requireSafeTotal(amountRaw, 'Expense amount') - : null; + const amountMinor = isSafeTotal(amountRaw) ? requireSafeTotal(amountRaw, 'Expense amount') : null; const dueMinor = amountMinor !== null && isSafeTotal(dueRaw) ? requireSafeTotal(dueRaw, 'Expense due total') From d0b370e48db3f70b87cea0c77fc88cf08d14fa35 Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Thu, 15 Oct 2026 00:00:14 -0400 Subject: [PATCH 6/7] fix(expenses): show out-of-range amounts as unavailable instead of zero Backend projects oversized legacy amounts as null; type the frontend fields as number | null and render common.notAvailable instead of formatting null as $0.00. --- frontend/src/app/(dashboard)/expenses/page.tsx | 5 ++++- .../src/components/expenses/ExpenseWorkflow.tsx | 16 +++++++++++----- frontend/src/lib/types.ts | 4 ++-- 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/frontend/src/app/(dashboard)/expenses/page.tsx b/frontend/src/app/(dashboard)/expenses/page.tsx index 6d0d31ccf..3eaa01f8b 100644 --- a/frontend/src/app/(dashboard)/expenses/page.tsx +++ b/frontend/src/app/(dashboard)/expenses/page.tsx @@ -40,7 +40,10 @@ export default function ExpensesPage() { const tOrders = useTranslations('orders'); const { currentTenant, refreshAuthContext } = useAuthStore(); const fmt = useFormatCurrency(); - const fmtMinor = (amountMinor: number, currencyCode: string) => fmt(amountMinor / getCurrencyMinorUnitFactor(currencyCode)); + const fmtMinor = (amountMinor: number | null, currencyCode: string) => + amountMinor === null + ? tCommon('notAvailable') + : fmt(amountMinor / getCurrencyMinorUnitFactor(currencyCode)); const minorFactor = getCurrencyMinorUnitFactor(currentTenant?.currency || ''); diff --git a/frontend/src/components/expenses/ExpenseWorkflow.tsx b/frontend/src/components/expenses/ExpenseWorkflow.tsx index aab7c2001..7c7f5de77 100644 --- a/frontend/src/components/expenses/ExpenseWorkflow.tsx +++ b/frontend/src/components/expenses/ExpenseWorkflow.tsx @@ -144,8 +144,10 @@ export default function ExpenseWorkflow({ setAttemptState({ attempt: next, storageBlocked: false }); // The freshly loaded record wins over the list row once it is that same record. const record = detail && detail.id === expense?.id ? detail : expense; - const fmtMinor = (amountMinor: number) => - fmt(amountMinor / getCurrencyMinorUnitFactor(record?.currency_code ?? context.currency_code)); + const fmtMinor = (amountMinor: number | null) => + amountMinor === null + ? tCommon('notAvailable') + : fmt(amountMinor / getCurrencyMinorUnitFactor(record?.currency_code ?? context.currency_code)); /** Replacing copies the source values into the fresh form. */ const openReplace = () => { @@ -154,7 +156,11 @@ export default function ExpenseWorkflow({ if (expense) { setDescription(expense.description); setCategoryId(expense.category_id); - setAmount(unitAdapter.toDisplay(expense.amount_minor / minorFactor)); + setAmount( + expense.amount_minor === null + ? '' + : unitAdapter.toDisplay(expense.amount_minor / minorFactor), + ); setIncurredOn(expense.incurred_on); setPayee(expense.payee ?? ''); setNotes(expense.notes ?? ''); @@ -409,7 +415,7 @@ export default function ExpenseWorkflow({ focusAmount(); return; } - if (cents > record.due_minor) { + if (record.due_minor === null || cents > record.due_minor) { setErrors({ amount: t('exceedsDue', { amount: fmtMinor(record.due_minor) }) }); focusAmount(); return; @@ -1039,7 +1045,7 @@ export default function ExpenseWorkflow({ type="button" onClick={openPayment} disabled={ - busy || locked || record.status !== 'active' || record.due_minor === 0 + busy || locked || record.status !== 'active' || !record.due_minor } data-testid="expense-record-payment" > diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 7238a3762..3b151b4fc 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -358,7 +358,7 @@ export interface ExpenseRecord { description: string; payee: string | null; notes: string | null; - amount_minor: number; + amount_minor: number | null; currency_code: string; incurred_on: string; created_by: string; @@ -369,7 +369,7 @@ export interface ExpenseRecord { void_reason: string | null; status: ExpenseStatus; paid_minor: number; - due_minor: number; + due_minor: number | null; } export interface ExpensePaymentRecord { From aacc9b3b7a8dd128a4a0e2bc94cb01aacfcd41b4 Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Thu, 15 Oct 2026 00:18:54 -0400 Subject: [PATCH 7/7] style(expenses): format changed frontend files with oxfmt --- .../src/app/(dashboard)/expenses/page.tsx | 235 ++++++++++++++---- .../components/expenses/ExpenseWorkflow.tsx | 8 +- 2 files changed, 188 insertions(+), 55 deletions(-) diff --git a/frontend/src/app/(dashboard)/expenses/page.tsx b/frontend/src/app/(dashboard)/expenses/page.tsx index 3eaa01f8b..fedca84be 100644 --- a/frontend/src/app/(dashboard)/expenses/page.tsx +++ b/frontend/src/app/(dashboard)/expenses/page.tsx @@ -52,16 +52,21 @@ export default function ExpensesPage() { const [filters, setFilters] = useState(null); // Rows carry the filter identity they were fetched for, so a filter change // renders as loading instead of showing rows that no longer match. - const [page, setPage] = useState<{ key: string; rows: ExpenseRecord[]; cursor: string | null }>( - { key: '', rows: [], cursor: null }, - ); + const [page, setPage] = useState<{ key: string; rows: ExpenseRecord[]; cursor: string | null }>({ + key: '', + rows: [], + cursor: null, + }); const [summary, setSummary] = useState<{ key: string; data: ExpenseSummary } | null>(null); const [loading, setLoading] = useState(true); const [loadingMore, setLoadingMore] = useState(false); const [loadError, setLoadError] = useState(null); const [denied, setDenied] = useState(false); const [refreshKey, setRefreshKey] = useState(0); - const [workflow, setWorkflow] = useState<{ mode: 'create' | 'detail'; expense: ExpenseRecord | null } | null>(null); + const [workflow, setWorkflow] = useState<{ + mode: 'create' | 'detail'; + expense: ExpenseRecord | null; + } | null>(null); // Every filter change starts a new request identity, so a slower earlier // response can never replace the rows the operator is looking at now. @@ -97,7 +102,9 @@ export default function ExpensesPage() { setContext(nextContext); setCategories(categoriesResponse.data.categories ?? []); const range = defaultRange(nextContext.business_date); - setFilters((current) => current ?? { ...range, categoryId: '', status: 'active', currency: '' }); + setFilters( + (current) => current ?? { ...range, categoryId: '', status: 'active', currency: '' }, + ); }) .catch((error) => { if (!active) return; @@ -106,14 +113,23 @@ export default function ExpensesPage() { void refreshAuthContext(); } setLoadError(t('loadFailed')); - setFilters((current) => current ?? { from: '', to: '', categoryId: '', status: 'active', currency: '' }); + setFilters( + (current) => + current ?? { from: '', to: '', categoryId: '', status: 'active', currency: '' }, + ); }) - .finally(() => { if (active) setLoading(false); }); - return () => { active = false; }; + .finally(() => { + if (active) setLoading(false); + }); + return () => { + active = false; + }; }, [refreshAuthContext, t]); useEffect(() => { - const handleFocus = () => { void refreshContext(); }; + const handleFocus = () => { + void refreshContext(); + }; window.addEventListener('focus', handleFocus); return () => window.removeEventListener('focus', handleFocus); }, [refreshContext]); @@ -139,7 +155,8 @@ export default function ExpensesPage() { ...(filters.currency ? { currency: filters.currency } : {}), }; - api.get('/expenses', { params: { ...params, limit: LIST_PAGE_SIZE }, signal: controller.signal }) + api + .get('/expenses', { params: { ...params, limit: LIST_PAGE_SIZE }, signal: controller.signal }) .then(({ data }) => { if (seq !== requestSeq.current) return; setPage({ key: pageKey, rows: data.expenses ?? [], cursor: data.nextCursor ?? null }); @@ -147,7 +164,11 @@ export default function ExpensesPage() { }) .catch((error: unknown) => { if (seq !== requestSeq.current) return; - if (error instanceof Error && (error.name === 'CanceledError' || error.name === 'AbortError')) return; + if ( + error instanceof Error && + (error.name === 'CanceledError' || error.name === 'AbortError') + ) + return; if ((error as { response?: { status?: number } })?.response?.status === 403) { setDenied(true); void refreshAuthContext(); @@ -155,12 +176,15 @@ export default function ExpensesPage() { setLoadError(t('loadFailed')); }); - api.get('/expenses/summary', { params: summaryParams, signal: controller.signal }) + api + .get('/expenses/summary', { params: summaryParams, signal: controller.signal }) .then(({ data }) => { if (seq !== requestSeq.current) return; setSummary({ key: pageKey, data: data as ExpenseSummary }); }) - .catch(() => { /* the list surfaces the failure; the summary is supplementary */ }); + .catch(() => { + /* the list surfaces the failure; the summary is supplementary */ + }); return () => controller.abort(); }, [filters, refreshAuthContext, refreshKey, t]); @@ -208,14 +232,22 @@ export default function ExpensesPage() { if (denied) { return ( -
-

{t('permissionDenied')}

+
+

+ {t('permissionDenied')} +

); } return ( -
+

{tNav('expenses')}

{tenantCan(currentTenant, 'expenses.manage') && ( @@ -234,7 +266,12 @@ export default function ExpensesPage() {
- +
- +
- +
- +
- +
@@ -305,60 +368,115 @@ export default function ExpensesPage() {
{(currentSummary?.totals ?? []).map((total) => (
-
-

{t('incurred')} · {total.currency_code}

-

{fmtMinor(total.incurred_minor, total.currency_code)}

+
+

+ {t('incurred')} · {total.currency_code} +

+

+ {fmtMinor(total.incurred_minor, total.currency_code)} +

-
+

{t('paidToDate')}

-

{fmtMinor(total.net_paid_minor, total.currency_code)}

+

+ {fmtMinor(total.net_paid_minor, total.currency_code)} +

-
+

{t('due')}

{fmtMinor(total.due_minor, total.currency_code)}

))} -

{t('summaryBasis')}

+

+ {t('summaryBasis')} +

{(currentSummary?.groups ?? []).length > 0 && ( -
    +
      {currentSummary?.groups.map((group) => (
    • - {group.category_name} · {fmtMinor(group.due_minor, group.currency_code)} {group.currency_code} + {group.category_name} · {fmtMinor(group.due_minor, group.currency_code)}{' '} + {group.currency_code}
    • ))}
    )}
-
+
- - - - - - - - + + + + + + + + {expenses.map((expense) => ( - + - - - + + +
{t('incurred')}{t('description')}{t('payee')}{tCommon('amount')}{t('netPaid')}{t('due')}{t('status')}{t('openDetail')} + {t('incurred')} + + {t('description')} + + {t('payee')} + + {tCommon('amount')} + + {t('netPaid')} + + {t('due')} + + {t('status')} + + {t('openDetail')} +
{expense.incurred_on}

{expense.description}

{expense.category_name}

{expense.payee || '—'}{fmtMinor(expense.amount_minor, expense.currency_code)}{fmtMinor(expense.paid_minor, expense.currency_code)}{fmtMinor(expense.due_minor, expense.currency_code)} + {fmtMinor(expense.amount_minor, expense.currency_code)} + + {fmtMinor(expense.paid_minor, expense.currency_code)} + + {fmtMinor(expense.due_minor, expense.currency_code)} + - {expense.status === 'active' ? tCommon('active') : expense.status === 'voided' ? t('statusVoided') : t('statusReplaced')} + {expense.status === 'active' + ? tCommon('active') + : expense.status === 'voided' + ? t('statusVoided') + : t('statusReplaced')}
- {(loading || stale) &&

{tCommon('loading')}

} + {(loading || stale) && ( +

{tCommon('loading')}

+ )} {!loading && !stale && expenses.length === 0 && ( -
+

{t('empty')}

)} - {loadError &&

{loadError}

} + {loadError && ( +

+ {loadError} +

+ )} {nextCursor && (
-
diff --git a/frontend/src/components/expenses/ExpenseWorkflow.tsx b/frontend/src/components/expenses/ExpenseWorkflow.tsx index 7c7f5de77..92a4a7ed8 100644 --- a/frontend/src/components/expenses/ExpenseWorkflow.tsx +++ b/frontend/src/components/expenses/ExpenseWorkflow.tsx @@ -147,7 +147,9 @@ export default function ExpenseWorkflow({ const fmtMinor = (amountMinor: number | null) => amountMinor === null ? tCommon('notAvailable') - : fmt(amountMinor / getCurrencyMinorUnitFactor(record?.currency_code ?? context.currency_code)); + : fmt( + amountMinor / getCurrencyMinorUnitFactor(record?.currency_code ?? context.currency_code), + ); /** Replacing copies the source values into the fresh form. */ const openReplace = () => { @@ -1044,9 +1046,7 @@ export default function ExpenseWorkflow({