Fixes every CRITICAL/HIGH finding from the 2026-06-09 full-codebase audit
(REVIEW_FINDINGS.md); each fix went through independent spec + code-quality
review. Plan and per-task log: docs/superpowers/plans/2026-06-10-audit-high-fix-pass.md
- attendance: schemas accept the combined local datetimes the forms/service
use (new dateTimeString helpers in schemas/common.ts), breaks persist on
create, AttendanceCreate submit rebuilt — every submit 400'd since 519edce
- 2fa: backup codes wired to /totp/backup-verify (+ remember-me parity),
enrollment QR generated locally via qrcode (CSP-blocked external service
also leaked the secret), dashboard shows per-user enrollment, not policy
- invoices/orders: per-line VAT survives re-saves (numberOr 0-respecting
coercion in formatters.ts), billing_text persists on update, issued-order
status transitions update UI gates
- trips: real pagination on all 3 pages, GET /trips/stats server aggregate
(shared buildTripsWhere + legacy distance coalesce), vehicle_id applies on
PUT with both-vehicle odometer recompute, print rebuilt (sync window.open,
escaped template, server totals)
- orders api: attachment_data PDF blob excluded from all non-binary reads
- warehouse: unit field is a Select over UnitEnum, receipt attachments
downloadable via new authenticated GET route
- downloads: shared RFC 5987 contentDisposition helper — Czech filenames no
longer 500 (warehouse, received-invoices, orders endpoints)
- misc: block-env hook actually blocks (exit 2 + stderr), project create
works with empty dates, NaN filter guards on trips endpoints
- deps: remove unused concurrently (clears both critical advisories), pin
@hono/node-server >=1.19.13 via overrides (clears the 3 moderates without
the Prisma 6 downgrade), drop deprecated @types stubs
Gates: tsc -b clean - vitest 30 files / 342 tests (31 new) - eslint 0 errors
- build OK
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
182 lines
6.7 KiB
TypeScript
182 lines
6.7 KiB
TypeScript
import { describe, it, expect, afterEach } from "vitest";
|
|
import { Prisma } from "@prisma/client";
|
|
import prisma from "../config/database";
|
|
import {
|
|
invoiceTotalWithVat,
|
|
createInvoice,
|
|
updateInvoice,
|
|
} from "../services/invoices.service";
|
|
import { UpdateInvoiceSchema } from "../schemas/invoices.schema";
|
|
|
|
// `invoiceTotalWithVat` receives a Prisma row whose money columns are real
|
|
// `Prisma.Decimal` instances (the model maps `@db.Decimal` → Decimal). Using
|
|
// real Decimals here — instead of the old `{ toNumber: () => N }` duck types —
|
|
// means a regression in genuine Decimal handling (e.g. swapping `.toNumber()`
|
|
// for `Number(decimal)`) is actually caught.
|
|
|
|
const dec = (n: number | string) => new Prisma.Decimal(n);
|
|
|
|
// Helper so each test reads as a small declarative table of lines.
|
|
function buildInvoice(opts: {
|
|
applyVat: boolean;
|
|
vatRate: number | null;
|
|
currency?: string;
|
|
items: Array<{
|
|
quantity: number | null;
|
|
unit_price: number | null;
|
|
vat_rate: number | null;
|
|
}>;
|
|
}) {
|
|
return {
|
|
apply_vat: opts.applyVat,
|
|
vat_rate: opts.vatRate === null ? null : dec(opts.vatRate),
|
|
currency: opts.currency ?? "CZK",
|
|
invoice_items: opts.items.map((i) => ({
|
|
quantity: i.quantity === null ? null : dec(i.quantity),
|
|
unit_price: i.unit_price === null ? null : dec(i.unit_price),
|
|
vat_rate: i.vat_rate === null ? null : dec(i.vat_rate),
|
|
})),
|
|
};
|
|
}
|
|
|
|
describe("invoiceTotalWithVat", () => {
|
|
it("calculates subtotal without VAT when apply_vat is false", () => {
|
|
const inv = buildInvoice({
|
|
applyVat: false,
|
|
vatRate: 21,
|
|
items: [{ quantity: 2, unit_price: 100, vat_rate: 21 }],
|
|
});
|
|
expect(invoiceTotalWithVat(inv)).toBe(200);
|
|
});
|
|
|
|
it("rounds each line VAT to 2 decimals before accumulation", () => {
|
|
// 3 items @ 33.33 with 21% VAT
|
|
// Line VAT = 33.33 * 0.21 = 6.9993 -> rounded to 7.00
|
|
// Total VAT = 7.00 * 3 = 21.00
|
|
// Subtotal = 33.33 * 3 = 99.99
|
|
// Total = 99.99 + 21.00 = 120.99
|
|
const inv = buildInvoice({
|
|
applyVat: true,
|
|
vatRate: 21,
|
|
items: Array.from({ length: 3 }, () => ({
|
|
quantity: 1,
|
|
unit_price: 33.33,
|
|
vat_rate: 21,
|
|
})),
|
|
});
|
|
expect(invoiceTotalWithVat(inv)).toBe(120.99);
|
|
});
|
|
|
|
it("treats a null quantity (and null unit_price) as 0 for that line", () => {
|
|
// The line is genuinely null here (not 0): quantity?.toNumber() === undefined
|
|
// -> Number(undefined) is NaN -> `|| 0` -> base 0. A second, well-formed
|
|
// line proves the null line contributes nothing while the rest still totals.
|
|
const inv = buildInvoice({
|
|
applyVat: true,
|
|
vatRate: 21,
|
|
items: [
|
|
{ quantity: null, unit_price: null, vat_rate: 21 },
|
|
{ quantity: 1, unit_price: 100, vat_rate: 21 }, // base 100, vat 21
|
|
],
|
|
});
|
|
expect(invoiceTotalWithVat(inv)).toBe(121);
|
|
});
|
|
|
|
it("returns 0 when every line is null", () => {
|
|
const inv = buildInvoice({
|
|
applyVat: true,
|
|
vatRate: 21,
|
|
items: [{ quantity: null, unit_price: null, vat_rate: null }],
|
|
});
|
|
expect(invoiceTotalWithVat(inv)).toBe(0);
|
|
});
|
|
|
|
it("applies mixed per-line vat_rate values, falling back to the invoice rate when a line rate is 0/absent", () => {
|
|
// Line 1: 2 x 100 @ 21% -> base 200, vat 42
|
|
// Line 2: 1 x 50 @ 12% -> base 50, vat 6
|
|
// Line 3: 1 x 100 @ 0% -> a line rate of 0 falls through to the invoice
|
|
// default (15%) per the `|| inv.vat_rate || 21` semantics -> vat 15
|
|
// Subtotal = 350, VAT = 63, Total = 413
|
|
const inv = buildInvoice({
|
|
applyVat: true,
|
|
vatRate: 15,
|
|
items: [
|
|
{ quantity: 2, unit_price: 100, vat_rate: 21 },
|
|
{ quantity: 1, unit_price: 50, vat_rate: 12 },
|
|
{ quantity: 1, unit_price: 100, vat_rate: 0 },
|
|
],
|
|
});
|
|
expect(invoiceTotalWithVat(inv)).toBe(413);
|
|
});
|
|
|
|
it("handles a negative (discount) line correctly", () => {
|
|
// Line 1: 1 x 1000 @ 21% -> base 1000, vat 210
|
|
// Line 2 (discount): 1 x -200 @ 21% -> base -200, vat -42
|
|
// Subtotal = 800, VAT = 168, Total = 968
|
|
const inv = buildInvoice({
|
|
applyVat: true,
|
|
vatRate: 21,
|
|
items: [
|
|
{ quantity: 1, unit_price: 1000, vat_rate: 21 },
|
|
{ quantity: 1, unit_price: -200, vat_rate: 21 },
|
|
],
|
|
});
|
|
expect(invoiceTotalWithVat(inv)).toBe(968);
|
|
});
|
|
});
|
|
|
|
/* -------------------------------------------------------------------------- */
|
|
/* PUT regression — billing_text must survive UpdateInvoiceSchema */
|
|
/* -------------------------------------------------------------------------- */
|
|
|
|
// The PUT route does `parseBody(UpdateInvoiceSchema, body)` and hands the
|
|
// PARSED data to `updateInvoice`. UpdateInvoiceSchema is a plain z.object,
|
|
// which STRIPS unknown keys — so a field missing from the schema is silently
|
|
// dropped before the service ever sees it, while the client still gets a
|
|
// success response. This block mirrors that exact flow against the real DB.
|
|
|
|
describe("updateInvoice — billing_text round-trips through UpdateInvoiceSchema", () => {
|
|
const invoiceIds: number[] = [];
|
|
|
|
afterEach(async () => {
|
|
for (const id of invoiceIds)
|
|
await prisma.invoices.deleteMany({ where: { id } });
|
|
invoiceIds.length = 0;
|
|
});
|
|
|
|
it("persists an edited billing_text (the schema must not strip it)", async () => {
|
|
const draft = await createInvoice({ billing_text: "Původní text" });
|
|
invoiceIds.push(draft.id);
|
|
|
|
// Mirror the route: raw body -> UpdateInvoiceSchema -> updateInvoice.
|
|
const parsed = UpdateInvoiceSchema.parse({
|
|
billing_text: "Fakturujeme Vám dle objednávky č. 123",
|
|
});
|
|
expect(parsed.billing_text).toBe("Fakturujeme Vám dle objednávky č. 123");
|
|
|
|
const res = await updateInvoice(draft.id, parsed);
|
|
expect("error" in res).toBe(false);
|
|
|
|
const row = await prisma.invoices.findUnique({ where: { id: draft.id } });
|
|
expect(row?.billing_text).toBe("Fakturujeme Vám dle objednávky č. 123");
|
|
});
|
|
|
|
it("clears billing_text when null is sent, and leaves it untouched when absent", async () => {
|
|
const draft = await createInvoice({ billing_text: "Text na PDF" });
|
|
invoiceIds.push(draft.id);
|
|
|
|
// Absent from the body -> updateInvoice's `!== undefined` guard skips it.
|
|
await updateInvoice(draft.id, UpdateInvoiceSchema.parse({ notes: "x" }));
|
|
let row = await prisma.invoices.findUnique({ where: { id: draft.id } });
|
|
expect(row?.billing_text).toBe("Text na PDF");
|
|
|
|
// Explicit null -> cleared (the strFields path maps falsy -> null).
|
|
await updateInvoice(
|
|
draft.id,
|
|
UpdateInvoiceSchema.parse({ billing_text: null }),
|
|
);
|
|
row = await prisma.invoices.findUnique({ where: { id: draft.id } });
|
|
expect(row?.billing_text).toBeNull();
|
|
});
|
|
});
|