diff --git a/docs/reference/api.md b/docs/reference/api.md index 9b4b6bb75..dcc5bced9 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -377,8 +377,12 @@ 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`; 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/frontend/src/app/(dashboard)/expenses/page.tsx b/frontend/src/app/(dashboard)/expenses/page.tsx index 6d0d31ccf..fedca84be 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 || ''); @@ -49,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. @@ -94,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; @@ -103,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]); @@ -136,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 }); @@ -144,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(); @@ -152,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]); @@ -205,14 +232,22 @@ export default function ExpensesPage() { if (denied) { return ( -
-

{t('permissionDenied')}

+
+

+ {t('permissionDenied')} +

); } return ( -
+

{tNav('expenses')}

{tenantCan(currentTenant, 'expenses.manage') && ( @@ -231,7 +266,12 @@ export default function ExpensesPage() {
- +
- +
- +
- +
- +
@@ -302,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 aab7c2001..92a4a7ed8 100644 --- a/frontend/src/components/expenses/ExpenseWorkflow.tsx +++ b/frontend/src/components/expenses/ExpenseWorkflow.tsx @@ -144,8 +144,12 @@ 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 +158,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 +417,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; @@ -1038,9 +1046,7 @@ export default function ExpenseWorkflow({