fix: 2026-06-09 full-codebase audit hardening
Validation: shared NaN-guarded Zod coercion helpers in schemas/common.ts replace the raw number|string transform idiom across every schema (the root-cause NaN bug class); emailOrEmpty + lenient isoDateString/timeString. Security: roles privilege-escalation closed; refresh-token family revocation on reuse; TOTP uses config params; read endpoints permission-guarded; received-invoices gross VAT on all paths; orders-pdf custom-items authz. Concurrency: $queryRaw SELECT...FOR UPDATE locks in ascending-id order (warehouse confirm/cancel, attendance lockUserRow); uniqueness checks moved into create transactions (TOCTOU -> 409); deterministic id tiebreak on second-precision timestamp ordering (plan resolveCell/resolveGrid, warehouse FIFO). Frontend: Rules-of-Hooks fixed across ~14 pages + PlanCellModal; UTC-date persisted fields; dashboard invalidation gaps; stale-closure confirm bugs. Tooling/tests: ESLint flat config (react-hooks/rules-of-hooks = error) + Prettier; tsconfig.test.json so tsc -b type-checks the tests; removed 3 dead deps; npm audit fix (8 -> 3). Suite 195 -> 247 (happy-path auth, FIFO oldest-first, flakiness fixes), isolated on app_test via .env.test with a hard-throw setup guard. Gates: tsc 0 | build 0 | vitest 247/247 | eslint 0 errors. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -22,15 +22,85 @@ const ALLOWED_SORT_FIELDS = [
|
||||
];
|
||||
|
||||
interface InvoiceItemInput {
|
||||
description?: string;
|
||||
item_description?: string;
|
||||
description?: string | null;
|
||||
item_description?: string | null;
|
||||
quantity?: number;
|
||||
unit?: string;
|
||||
unit?: string | null;
|
||||
unit_price?: number;
|
||||
vat_rate?: number;
|
||||
position?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Body shape accepted by createInvoice/updateInvoice. All fields optional and
|
||||
* loosely typed because the payload arrives validated-but-coerced from the Zod
|
||||
* route layer (numbers may still be string-from-form). Kept permissive so the
|
||||
* existing String()/Number() coercions below stay correct; replaces the old
|
||||
* `Record<string, any>`.
|
||||
*/
|
||||
export interface InvoiceInput {
|
||||
invoice_number?: string | number | null;
|
||||
order_id?: number | string | null;
|
||||
customer_id?: number | string | null;
|
||||
status?: string;
|
||||
currency?: string;
|
||||
vat_rate?: number | string | null;
|
||||
apply_vat?: boolean | number | string;
|
||||
payment_method?: string | null;
|
||||
constant_symbol?: string | null;
|
||||
bank_name?: string | null;
|
||||
bank_swift?: string | null;
|
||||
bank_iban?: string | null;
|
||||
bank_account?: string | null;
|
||||
issue_date?: string | null;
|
||||
due_date?: string | null;
|
||||
tax_date?: string | null;
|
||||
issued_by?: string | null;
|
||||
billing_text?: string | null;
|
||||
language?: string;
|
||||
notes?: string | null;
|
||||
internal_notes?: string | null;
|
||||
paid_date?: string | null;
|
||||
items?: InvoiceItemInput[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single rounding-correct money/VAT core. All three former implementations
|
||||
* (computeInvoiceTotals, invoiceTotalWithVat, the stats VAT loop) funnel through
|
||||
* this so the subtotal/accumulation/rounding logic cannot diverge again.
|
||||
*
|
||||
* Each caller resolves its own per-line VAT *rate* before calling (the three
|
||||
* paths historically differ in how a line rate of 0 is treated — that nuance
|
||||
* is preserved by keeping rate resolution caller-side via `resolveRate`).
|
||||
*
|
||||
* - `subtotal` = Σ(qty × unit_price) over lines.
|
||||
* - `vat` = Σ of per-line VAT. When `roundPerLine` is true each line's VAT is
|
||||
* rounded to 2 decimals BEFORE accumulation (invoice totals); when false the
|
||||
* raw per-line VAT is accumulated and the caller rounds the sum (stats).
|
||||
*
|
||||
* Numeric outputs are byte-for-byte identical to the previous three code paths.
|
||||
*/
|
||||
function computeMoney<T>(
|
||||
lines: T[],
|
||||
applyVat: boolean | null,
|
||||
getBase: (line: T) => number,
|
||||
resolveRate: (line: T) => number,
|
||||
roundPerLine: boolean,
|
||||
): { subtotal: number; vat: number } {
|
||||
let subtotal = 0;
|
||||
let vat = 0;
|
||||
for (const line of lines) {
|
||||
const base = getBase(line);
|
||||
subtotal += base;
|
||||
if (applyVat) {
|
||||
const lineVat = base * (resolveRate(line) / 100);
|
||||
vat += roundPerLine ? Math.round(lineVat * 100) / 100 : lineVat;
|
||||
}
|
||||
}
|
||||
return { subtotal, vat };
|
||||
}
|
||||
|
||||
interface ListInvoicesParams {
|
||||
page: number;
|
||||
limit: number;
|
||||
@@ -49,23 +119,19 @@ function computeInvoiceTotals(
|
||||
applyVat: boolean | null,
|
||||
defaultVatRate: unknown,
|
||||
) {
|
||||
const subtotal = items.reduce(
|
||||
(s, i) => s + (Number(i.quantity) || 0) * (Number(i.unit_price) || 0),
|
||||
0,
|
||||
const { subtotal, vat: vatAmount } = computeMoney(
|
||||
items,
|
||||
applyVat,
|
||||
(i) => (Number(i.quantity) || 0) * (Number(i.unit_price) || 0),
|
||||
// Preserve original semantics: a line vat_rate of 0 (non-null, non-"") IS used.
|
||||
(i) =>
|
||||
i.vat_rate != null && i.vat_rate !== ""
|
||||
? Number(i.vat_rate)
|
||||
: defaultVatRate != null
|
||||
? Number(defaultVatRate)
|
||||
: 21,
|
||||
true, // round each line's VAT before accumulation
|
||||
);
|
||||
const vatAmount = applyVat
|
||||
? items.reduce((s, i) => {
|
||||
const base = (Number(i.quantity) || 0) * (Number(i.unit_price) || 0);
|
||||
const vatRate =
|
||||
i.vat_rate != null && i.vat_rate !== ""
|
||||
? Number(i.vat_rate)
|
||||
: defaultVatRate != null
|
||||
? Number(defaultVatRate)
|
||||
: 21;
|
||||
const vat = base * (vatRate / 100);
|
||||
return s + Math.round(vat * 100) / 100;
|
||||
}, 0)
|
||||
: 0;
|
||||
return {
|
||||
subtotal: Math.round(subtotal * 100) / 100,
|
||||
vat_amount: Math.round(vatAmount * 100) / 100,
|
||||
@@ -171,28 +237,19 @@ export function invoiceTotalWithVat(inv: {
|
||||
vat_rate: { toNumber(): number } | null;
|
||||
}>;
|
||||
}) {
|
||||
const sub = inv.invoice_items.reduce(
|
||||
(s, i) =>
|
||||
s +
|
||||
const { subtotal, vat } = computeMoney(
|
||||
inv.invoice_items,
|
||||
inv.apply_vat,
|
||||
(i) =>
|
||||
(Number(i.quantity?.toNumber()) || 0) *
|
||||
(Number(i.unit_price?.toNumber()) || 0),
|
||||
0,
|
||||
(Number(i.unit_price?.toNumber()) || 0),
|
||||
// Preserve original `||` semantics: a line rate of 0/NaN falls through to
|
||||
// the invoice default, then 21.
|
||||
(i) =>
|
||||
Number(i.vat_rate?.toNumber()) || Number(inv.vat_rate?.toNumber()) || 21,
|
||||
true, // round each line's VAT before accumulation
|
||||
);
|
||||
const vat = inv.apply_vat
|
||||
? inv.invoice_items.reduce((s, i) => {
|
||||
const base =
|
||||
(Number(i.quantity?.toNumber()) || 0) *
|
||||
(Number(i.unit_price?.toNumber()) || 0);
|
||||
const lineVat =
|
||||
base *
|
||||
((Number(i.vat_rate?.toNumber()) ||
|
||||
Number(inv.vat_rate?.toNumber()) ||
|
||||
21) /
|
||||
100);
|
||||
return s + Math.round(lineVat * 100) / 100;
|
||||
}, 0)
|
||||
: 0;
|
||||
return sub + vat;
|
||||
return subtotal + vat;
|
||||
}
|
||||
|
||||
export async function getInvoiceStats(queryMonth?: number, queryYear?: number) {
|
||||
@@ -257,17 +314,21 @@ export async function getInvoiceStats(queryMonth?: number, queryYear?: number) {
|
||||
|
||||
const paidInvoices = monthInvoices.filter((i) => i.status === "paid");
|
||||
|
||||
// VAT for the month, per currency. Uses the shared money core with
|
||||
// roundPerLine=false (the stats path historically accumulates RAW per-line
|
||||
// VAT and rounds only the final sum — preserved byte-for-byte).
|
||||
const vatMap: Record<string, number> = {};
|
||||
for (const inv of monthInvoices) {
|
||||
if (!inv.apply_vat) continue;
|
||||
const cur = inv.currency || "CZK";
|
||||
for (const item of inv.invoice_items) {
|
||||
const base =
|
||||
(Number(item.quantity) || 0) * (Number(item.unit_price) || 0);
|
||||
const vat =
|
||||
base * ((Number(item.vat_rate) || Number(inv.vat_rate) || 21) / 100);
|
||||
vatMap[cur] = (vatMap[cur] || 0) + vat;
|
||||
}
|
||||
const { vat } = computeMoney(
|
||||
inv.invoice_items,
|
||||
inv.apply_vat,
|
||||
(item) => (Number(item.quantity) || 0) * (Number(item.unit_price) || 0),
|
||||
(item) => Number(item.vat_rate) || Number(inv.vat_rate) || 21,
|
||||
false, // accumulate raw per-line VAT; round only the per-currency sum
|
||||
);
|
||||
vatMap[cur] = (vatMap[cur] || 0) + vat;
|
||||
}
|
||||
const vatAmounts = Object.entries(vatMap)
|
||||
.filter(([, v]) => v > 0)
|
||||
@@ -275,21 +336,31 @@ export async function getInvoiceStats(queryMonth?: number, queryYear?: number) {
|
||||
amount: Math.round(amount * 100) / 100,
|
||||
currency,
|
||||
}));
|
||||
// VAT also needs conversion
|
||||
let vatCzkConverted = 0;
|
||||
for (const [cur, amount] of Object.entries(vatMap)) {
|
||||
vatCzkConverted += await toCzk(amount, cur);
|
||||
}
|
||||
|
||||
// VAT also needs CZK conversion — run each currency's toCzk concurrently.
|
||||
const vatCzkConverted = (
|
||||
await Promise.all(
|
||||
Object.entries(vatMap).map(([cur, amount]) => toCzk(amount, cur)),
|
||||
)
|
||||
).reduce((s, v) => s + v, 0);
|
||||
|
||||
// These aggregates/conversions are independent — run them concurrently
|
||||
// instead of serially awaiting each in the return literal.
|
||||
const [paidMonthCzk, awaitingCzk, overdueCzk] = await Promise.all([
|
||||
sumCzk(paidInvoices),
|
||||
sumCzk(awaitingInvoices),
|
||||
sumCzk(overdueInvoices),
|
||||
]);
|
||||
|
||||
return {
|
||||
paid_month: aggregateByCurrency(paidInvoices),
|
||||
paid_month_czk: await sumCzk(paidInvoices),
|
||||
paid_month_czk: paidMonthCzk,
|
||||
paid_month_count: paidInvoices.length,
|
||||
awaiting: aggregateByCurrency(awaitingInvoices),
|
||||
awaiting_czk: await sumCzk(awaitingInvoices),
|
||||
awaiting_czk: awaitingCzk,
|
||||
awaiting_count: awaitingInvoices.length,
|
||||
overdue: aggregateByCurrency(overdueInvoices),
|
||||
overdue_czk: await sumCzk(overdueInvoices),
|
||||
overdue_czk: overdueCzk,
|
||||
overdue_count: overdueInvoices.length,
|
||||
vat_month: vatAmounts,
|
||||
vat_month_czk: Math.round(vatCzkConverted * 100) / 100,
|
||||
@@ -315,6 +386,9 @@ export async function getOrderDataForInvoice(orderId: number) {
|
||||
};
|
||||
}
|
||||
|
||||
// NOTE: returns `null` (not `{ error, status }`) on not-found — intentional and
|
||||
// load-bearing: routes/admin/invoices.ts checks `if (!invoice) 404`. Returning a
|
||||
// truthy `{ error }` object here would bypass that 404 guard, so this stays null.
|
||||
export async function getInvoice(id: number) {
|
||||
const invoice = await prisma.invoices.findUnique({
|
||||
where: { id },
|
||||
@@ -336,7 +410,7 @@ export async function getInvoice(id: number) {
|
||||
};
|
||||
}
|
||||
|
||||
export async function createInvoice(body: Record<string, any>) {
|
||||
export async function createInvoice(body: InvoiceInput) {
|
||||
const invoiceNumber =
|
||||
body.invoice_number !== undefined && body.invoice_number !== null
|
||||
? String(body.invoice_number)
|
||||
@@ -372,7 +446,7 @@ export async function createInvoice(body: Record<string, any>) {
|
||||
|
||||
if (Array.isArray(body.items)) {
|
||||
await prisma.invoice_items.createMany({
|
||||
data: (body.items as InvoiceItemInput[]).map((item, i) => ({
|
||||
data: body.items.map((item, i) => ({
|
||||
invoice_id: invoice.id,
|
||||
description: item.description ?? null,
|
||||
item_description: item.item_description ?? null,
|
||||
@@ -388,7 +462,7 @@ export async function createInvoice(body: Record<string, any>) {
|
||||
return invoice;
|
||||
}
|
||||
|
||||
export async function updateInvoice(id: number, body: Record<string, any>) {
|
||||
export async function updateInvoice(id: number, body: InvoiceInput) {
|
||||
const existing = await prisma.invoices.findUnique({ where: { id } });
|
||||
if (!existing) return { error: "not_found" as const };
|
||||
|
||||
@@ -470,7 +544,7 @@ export async function updateInvoice(id: number, body: Record<string, any>) {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.invoice_items.deleteMany({ where: { invoice_id: id } });
|
||||
await tx.invoice_items.createMany({
|
||||
data: (body.items as InvoiceItemInput[]).map((item, i) => ({
|
||||
data: body.items!.map((item, i) => ({
|
||||
invoice_id: id,
|
||||
description: item.description ?? null,
|
||||
item_description: item.item_description ?? null,
|
||||
@@ -493,9 +567,13 @@ export async function deleteInvoice(id: number) {
|
||||
|
||||
await prisma.invoices.delete({ where: { id } });
|
||||
|
||||
const year = existing.invoice_number
|
||||
? Number(existing.invoice_number.split("/")[1]) || new Date().getFullYear()
|
||||
: new Date().getFullYear();
|
||||
// Derive the sequence year from the invoice number ("<seq>/<YYYY>"). Guard the
|
||||
// split: a non-standard number (no "/" or a non-4-digit / non-numeric part)
|
||||
// falls back to the current year rather than releasing a bogus sequence.
|
||||
const yearPart = existing.invoice_number?.split("/")[1];
|
||||
const parsedYear =
|
||||
yearPart && /^\d{4}$/.test(yearPart) ? Number(yearPart) : NaN;
|
||||
const year = Number.isNaN(parsedYear) ? new Date().getFullYear() : parsedYear;
|
||||
await releaseInvoiceNumber(year, existing.invoice_number ?? undefined);
|
||||
|
||||
return existing;
|
||||
|
||||
Reference in New Issue
Block a user