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:
BOHA
2026-06-09 06:45:26 +02:00
parent c454d1a3fc
commit 519edce373
179 changed files with 7179 additions and 2844 deletions

View File

@@ -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;