From cde4be2cf8d91b124e19a9f392fc43e98fc942a5 Mon Sep 17 00:00:00 2001 From: Bilal Mansouri <124762008+bighadj22@users.noreply.github.com> Date: Sun, 6 Sep 2026 15:43:41 +0100 Subject: [PATCH] fix(core): money & inventory integrity hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every fix in this change was first proven red on real D1 (miniflare + real migrations, no mocks for the flows), then fixed and locked with a regression test at the same seam. Storefront / pricing - Server-authoritative pricing: createStoreOrder resolves unit prices from the catalog (product row / variant rows) instead of trusting the client-sent pricePerUnit; order price = true sum of line totals. A forged 1 DZD price no longer propagates into codAmount, customer totalSpent, or driver pendingCash. - Delivery availability is enforced: a wilaya with no rule under the default profile (or a disabled delivery type) refuses the order with DELIVERY_NOT_AVAILABLE before the customer row is created, instead of silently charging 0. Orders / stock - createOrder deducts stock via guarded subselect ledger writes inside the atomic batch: insufficient stock rolls back the whole order (no silent floor-at-zero), and concurrent orders can no longer double-sell the last unit (proven: two racing qty-1 orders → exactly one commits). - deleteOrder reverses driver credit (totalDelivered, totalEarnings, pendingCash) for unsettled delivered orders; settled orders keep counters so append-only payment history stays reconciled. - deleteOrder skips the totalSpent decrement when cancel/return already rolled the spend back (cancel + delete no longer double-decrements). - syncOrderAfterCarrierUpdate recomputes codAmount = price + deliveryFee so settlement follows carrier amount updates. Drivers - createDriverPayment settles in one atomic batch (payment row, order links, driver counters) — a mid-sequence failure can no longer leave settled orders with inflated pendingCash and no retry path. - getDriverById now reports cashReconciliation: pendingCash vs the real sum of delivered-unsettled orders, with drift surfaced in the dashboard DriverDetail (warning alert, ar/en/fr). Stock / ledger discipline - Catalog inventory edits write movements: updateProduct and updateVariant log ADJUSTMENT_ADD/REMOVE with true deltas, batched with the write; createVariant logs opening stock; deleteVariant relies on the variant FK cascade so the ledger sum stays reconciled. - adjustStock validates movement-type/delta sign coherence, keeps the friendly INSUFFICIENT_STOCK 422, and applies the change as a guarded atomic batch (movement + inventory update) — no lost deltas, no ledger divergence. Errors - Unknown errors return a clean envelope: "An unexpected error occurred" + requestId in context for log correlation. The [variable] redaction soup is gone. Tests - New real-D1 suites: money-integrity-probes, orders.delete-e2e, orders.stock-lifecycle-e2e, flagged-fixes (39 tests over the seams above, including fault-injection atomicity probes). - All e2e files batch migrations through d1.batch — cuts the Miniflare proxy fetch storm that exhausted loopback ports and flaked the suite. Verified: cod-server 117 files / 1705 tests green, typecheck clean; cod-client-astro typecheck + 142 tests green. --- cod-client-astro/locales/ar/delivery.json | 1 + cod-client-astro/locales/en/delivery.json | 1 + cod-client-astro/locales/fr/delivery.json | 1 + .../delivery/components/DriverDetail.tsx | 26 ++ .../src/features/delivery/types.ts | 8 + .../customers/customers.filters-e2e.test.ts | 6 +- .../src/endpoints/driver-payments/queries.ts | 72 +-- .../src/endpoints/drivers/drivers.test.ts | 9 +- .../src/endpoints/drivers/handlers.test.ts | 3 + .../src/endpoints/drivers/routes.test.ts | 1 + .../orders/flagged-fixes.e2e.test.ts | 362 +++++++++++++++ .../orders/money-integrity-probes.e2e.test.ts | 287 ++++++++++++ .../orders/orders.delete-e2e.test.ts | 132 ++++++ .../orders/orders.keyset-e2e.test.ts | 6 +- .../endpoints/orders/orders.read-e2e.test.ts | 6 +- .../orders/orders.stock-lifecycle-e2e.test.ts | 431 ++++++++++++++++++ .../src/endpoints/orders/orders.test.ts | 5 +- .../orders/orders.webhook-e2e.test.ts | 6 +- .../products/products.list-e2e.test.ts | 6 +- cod-server/src/endpoints/stock/CONTEXT.md | 1 + .../endpoints/stock/list-count-e2e.test.ts | 6 +- cod-server/src/endpoints/stock/queries.ts | 109 +++-- .../stock/stock.overview-e2e.test.ts | 6 +- cod-server/src/endpoints/stock/stock.test.ts | 5 + cod-server/src/endpoints/store/CONTEXT.md | 10 +- cod-server/src/endpoints/store/handlers.ts | 25 +- .../endpoints/store/store.queries-e2e.test.ts | 6 +- .../src/endpoints/store/store.queries.test.ts | 18 +- cod-server/src/endpoints/store/validation.ts | 2 + .../src/endpoints/variants/variants.test.ts | 17 +- cod-server/src/middleware/error.test.ts | 14 +- cod-server/src/middleware/error.ts | 57 +-- cod-shared/queries/drivers.ts | 59 ++- cod-shared/queries/orders.ts | 395 +++++++++------- cod-shared/queries/products.ts | 42 +- cod-shared/queries/store.ts | 111 +++-- cod-shared/queries/variants.ts | 161 ++++++- 37 files changed, 2051 insertions(+), 362 deletions(-) create mode 100644 cod-server/src/endpoints/orders/flagged-fixes.e2e.test.ts create mode 100644 cod-server/src/endpoints/orders/money-integrity-probes.e2e.test.ts create mode 100644 cod-server/src/endpoints/orders/orders.delete-e2e.test.ts create mode 100644 cod-server/src/endpoints/orders/orders.stock-lifecycle-e2e.test.ts diff --git a/cod-client-astro/locales/ar/delivery.json b/cod-client-astro/locales/ar/delivery.json index d8efcc9..b159c55 100644 --- a/cod-client-astro/locales/ar/delivery.json +++ b/cod-client-astro/locales/ar/delivery.json @@ -78,6 +78,7 @@ "active_orders": "الطلبات النشطة", "total_delivered": "إجمالي التوصيلات", "pending_cash": "النقد المعلق", + "cash_drift_warning": "عدم تطابق في صندوق السائق: المعلق {pending} لكن مجموع الطلبات غير المسواة {orders}. الفرق: {drift}. راجع التسويات قبل الدفع.", "pending_cash_hint": "مبلغ التحصيل لم يُسلَّم بعد", "total_paid": "إجمالي المُسلَّم", "total_paid_hint": "إجمالي النقد المُسلَّم للمتجر", diff --git a/cod-client-astro/locales/en/delivery.json b/cod-client-astro/locales/en/delivery.json index 01cd01a..f68c53e 100644 --- a/cod-client-astro/locales/en/delivery.json +++ b/cod-client-astro/locales/en/delivery.json @@ -78,6 +78,7 @@ "active_orders": "Active Orders", "total_delivered": "Total Delivered", "pending_cash": "Pending Cash", + "cash_drift_warning": "Driver cash ledger mismatch: held {pending} but unsettled orders total {orders}. Difference: {drift}. Review settlements before paying out.", "pending_cash_hint": "COD collected, not yet remitted", "total_paid": "Total Remitted", "total_paid_hint": "Total cash handed to shop", diff --git a/cod-client-astro/locales/fr/delivery.json b/cod-client-astro/locales/fr/delivery.json index 68b159d..d2f099f 100644 --- a/cod-client-astro/locales/fr/delivery.json +++ b/cod-client-astro/locales/fr/delivery.json @@ -78,6 +78,7 @@ "active_orders": "Commandes actives", "total_delivered": "Total livré", "pending_cash": "Espèces en attente", + "cash_drift_warning": "Écart de caisse livreur : {pending} détenus mais {orders} de commandes non réglées. Différence : {drift}. Vérifiez les règlements avant tout paiement.", "pending_cash_hint": "COD collecté, pas encore remis", "total_paid": "Total reversé", "total_paid_hint": "Total des espèces remises à la boutique", diff --git a/cod-client-astro/src/features/delivery/components/DriverDetail.tsx b/cod-client-astro/src/features/delivery/components/DriverDetail.tsx index cc51b2c..13d6c46 100644 --- a/cod-client-astro/src/features/delivery/components/DriverDetail.tsx +++ b/cod-client-astro/src/features/delivery/components/DriverDetail.tsx @@ -126,6 +126,32 @@ export function DriverDetail({ driverId }: { driverId: string }) { )} + {driver.cashReconciliation && driver.cashReconciliation.drift !== 0 && ( + + + + {t("driver_card.cash_drift_warning") + .replace( + "{pending}", + formatDeliveryMoney(driver.cashReconciliation.pendingCash, locale), + ) + .replace( + "{orders}", + formatDeliveryMoney( + driver.cashReconciliation.pendingOrdersTotal, + locale, + ), + ) + .replace( + "{drift}", + formatDeliveryMoney( + Math.abs(driver.cashReconciliation.drift), + locale, + ), + )} + + + )}
{ registry.push(mf); rawD1 = await mf.getD1Database("DB"); const dir = resolve(__dirname, "../../db/migrations"); + const preparedStatements: D1PreparedStatement[] = []; for (const file of readdirSync(dir).filter((f) => f.endsWith(".sql")).sort()) { const statements = readFileSync(`${dir}/${file}`, "utf8") .split("--> statement-breakpoint") @@ -46,9 +47,12 @@ beforeAll(async () => { .map((s) => s.replace(/;+\s*$/, "").trim()) .filter((s) => s.replace(/--[^\n]*/g, "").trim().length > 0); for (const statement of statements) { - await rawD1.prepare(statement).run(); + preparedStatements.push(rawD1.prepare(statement)); } } + for (let i = 0; i < preparedStatements.length; i += 50) { + await rawD1.batch(preparedStatements.slice(i, i + 50)); + } db = drizzle(rawD1 as unknown as D1Database, { schema }) as unknown as AppDb; const now = new Date().toISOString(); diff --git a/cod-server/src/endpoints/driver-payments/queries.ts b/cod-server/src/endpoints/driver-payments/queries.ts index afe2198..62e2f51 100644 --- a/cod-server/src/endpoints/driver-payments/queries.ts +++ b/cod-server/src/endpoints/driver-payments/queries.ts @@ -11,6 +11,7 @@ import { ERROR_CODES } from "../../../../cod-shared/errors/codes"; export { getDriverPayments, getPendingSettlementOrders } from "../../../../cod-shared/queries/driver-payments"; type Database = ReturnType; +type BatchStatement = Parameters[0][number]; /** * Create a driver payment record and settle the selected orders. @@ -82,46 +83,51 @@ export async function createDriverPayment( const id = crypto.randomUUID(); const now = new Date().toISOString(); - // Insert payment record - await db.insert(driverPayments).values({ - id, - driverId, - type, - amount, - orderCount: selectedOrders.length, - notes: notes ?? null, - createdBy, - createdByName, - createdAt: now, - }); + // One atomic batch: payment row + order linking + driver counters commit + // together or not at all. The previous separate awaits left drift windows — + // a failure after linking marked orders settled while pendingCash stayed + // inflated (phantom cash, unfixable via retry because of the settled guard). + const statements: BatchStatement[] = [ + db.insert(driverPayments).values({ + id, + driverId, + type, + amount, + orderCount: selectedOrders.length, + notes: notes ?? null, + createdBy, + createdByName, + createdAt: now, + }), + ]; - // Link orders to payment (COD settlement) if (type === "cod_remittance" || type === "net_settlement") { - await db - .update(orders) - .set({ codPaymentId: id }) - .where(inArray(orders.id, orderIds)); + statements.push( + db + .update(orders) + .set({ codPaymentId: id }) + .where(inArray(orders.id, orderIds)), + db + .update(drivers) + .set({ + pendingCash: sql`MAX(0, ${drivers.pendingCash} - ${codTotal})`, + totalPaid: sql`${drivers.totalPaid} + ${codTotal}`, + updatedAt: now, + }) + .where(eq(drivers.id, driverId)), + ); } - // Link orders to payment (fee settlement) if (type === "fee_payment" || type === "net_settlement") { - await db - .update(orders) - .set({ feePaymentId: id }) - .where(inArray(orders.id, orderIds)); + statements.push( + db + .update(orders) + .set({ feePaymentId: id }) + .where(inArray(orders.id, orderIds)), + ); } - // Update driver aggregate counters for COD - if (type === "cod_remittance" || type === "net_settlement") { - await db - .update(drivers) - .set({ - pendingCash: sql`${drivers.pendingCash} - ${codTotal}`, - totalPaid: sql`${drivers.totalPaid} + ${codTotal}`, - updatedAt: now, - }) - .where(eq(drivers.id, driverId)); - } + await db.batch(statements as [BatchStatement, ...BatchStatement[]]); return { id, driverId, type, amount, orderCount: selectedOrders.length, notes: notes ?? null, createdBy, createdByName, createdAt: now }; } diff --git a/cod-server/src/endpoints/drivers/drivers.test.ts b/cod-server/src/endpoints/drivers/drivers.test.ts index 4fb489a..f2dcd89 100644 --- a/cod-server/src/endpoints/drivers/drivers.test.ts +++ b/cod-server/src/endpoints/drivers/drivers.test.ts @@ -177,16 +177,21 @@ describe("updateDriverStatus", () => { }); it("succeeds for an existing driver", async () => { - // getDriverById(1): drivers.get + compStats.get + orders.all + // getDriverById(1): drivers.get + compStats.get + [orders.all + + // reconciliation: pendingCash.get + pending aggregate.get] // UPDATE drivers (run — no queue consumption) - // getDriverById(2): drivers.get + compStats.get + orders.all + // getDriverById(2): same five reads const db = makeMockDb([ f(driverRow()), f({ c: 0, totalFee: 0 }), a([]), + f({ pending_cash: 0 }), + f({ total: 0, c: 0 }), f(driverRow({ status: "busy" })), f({ c: 0, totalFee: 0 }), a([]), + f({ pending_cash: 0 }), + f({ total: 0, c: 0 }), ]); const result = await updateDriverStatus(db, "drv_1", "busy"); expect(result).not.toBeNull(); diff --git a/cod-server/src/endpoints/drivers/handlers.test.ts b/cod-server/src/endpoints/drivers/handlers.test.ts index 2aa18a9..cffff6e 100644 --- a/cod-server/src/endpoints/drivers/handlers.test.ts +++ b/cod-server/src/endpoints/drivers/handlers.test.ts @@ -106,6 +106,7 @@ describe("Drivers Endpoint - Error Scenarios", () => { createdAt: new Date("2024-01-01").toISOString(), updatedAt: new Date("2024-01-15").toISOString(), compensationWilayaCount: 0, + cashReconciliation: { pendingCash: 0, pendingOrdersTotal: 0, pendingOrdersCount: 0, drift: 0 }, recentOrders: [], }); @@ -224,6 +225,7 @@ describe("Drivers Endpoint - Error Scenarios", () => { createdAt: new Date("2024-01-01").toISOString(), updatedAt: new Date("2024-01-15").toISOString(), compensationWilayaCount: 0, + cashReconciliation: { pendingCash: 0, pendingOrdersTotal: 0, pendingOrdersCount: 0, drift: 0 }, recentOrders: [], }); @@ -267,6 +269,7 @@ describe("Drivers Endpoint - Error Scenarios", () => { createdAt: new Date("2024-01-01").toISOString(), updatedAt: new Date("2024-01-15").toISOString(), compensationWilayaCount: 0, + cashReconciliation: { pendingCash: 0, pendingOrdersTotal: 0, pendingOrdersCount: 0, drift: 0 }, recentOrders: [], }); diff --git a/cod-server/src/endpoints/drivers/routes.test.ts b/cod-server/src/endpoints/drivers/routes.test.ts index 755c998..4ea7e2e 100644 --- a/cod-server/src/endpoints/drivers/routes.test.ts +++ b/cod-server/src/endpoints/drivers/routes.test.ts @@ -56,6 +56,7 @@ function driverRow(overrides: Record = {}) { createdAt: NOW, updatedAt: NOW, compensationWilayaCount: 12, + cashReconciliation: { pendingCash: 0, pendingOrdersTotal: 0, pendingOrdersCount: 0, drift: 0 }, recentOrders: [], ...overrides, }; diff --git a/cod-server/src/endpoints/orders/flagged-fixes.e2e.test.ts b/cod-server/src/endpoints/orders/flagged-fixes.e2e.test.ts new file mode 100644 index 0000000..0930d42 --- /dev/null +++ b/cod-server/src/endpoints/orders/flagged-fixes.e2e.test.ts @@ -0,0 +1,362 @@ +/** + * Flagged-issues probe matrix — real D1 (red-first audit round 2) + * + * F1 storefront delivery fee: uncovered/disabled wilaya must refuse, not charge 0 + * F2 carrier amount sync must update codAmount, not just price + * F3 catalog inventory edits (product/variant update, create, delete) must + * write stock movements — the ledger has to reconcile to inventory + * F4 manual adjustStock must be race-free and atomic (update + ledger together) + * F5 driver cash reconciliation: pendingCash vs pending orders, drift visible + */ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { Miniflare } from "miniflare"; +import { readFileSync, readdirSync } from "node:fs"; +import { resolve } from "node:path"; +import { drizzle } from "drizzle-orm/d1"; +import { eq, sql } from "drizzle-orm"; +import * as schema from "@/db/schema"; +import type { AppDb } from "@/db"; +import { createOrder, updateOrderStatus, syncOrderAfterCarrierUpdate } from "../../../../cod-shared/queries/orders"; +import { updateVariant, createVariant, deleteVariant } from "../../../../cod-shared/queries/variants"; +import { updateProduct } from "../../../../cod-shared/queries/products"; +import { adjustStock } from "../stock/queries"; +import { getDriverById } from "../../../../cod-shared/queries/drivers"; + +let db: AppDb; +let rawD1: D1Database; + +beforeAll(async () => { + const mf = new Miniflare({ + script: "export default { fetch() { return new Response('ok'); } }", + modules: true, + d1Databases: { DB: "test-db" }, + }); + rawD1 = await mf.getD1Database("DB"); + const dir = resolve(__dirname, "../../db/migrations"); + const prepared: D1PreparedStatement[] = []; + for (const file of readdirSync(dir).filter((f) => f.endsWith(".sql")).sort()) { + const statements = readFileSync(`${dir}/${file}`, "utf8") + .split("--> statement-breakpoint") + .flatMap((s) => s.split(/;\s*\n/)) + .map((s) => s.replace(/;+\s*$/, "").trim()) + .filter((s) => s.replace(/--[^\n]*/g, "").trim().length > 0); + for (const statement of statements) prepared.push(rawD1.prepare(statement)); + } + for (let i = 0; i < prepared.length; i += 50) { + await rawD1.batch(prepared.slice(i, i + 50)); + } + db = drizzle(rawD1 as unknown as D1Database, { schema }) as unknown as AppDb; + registry.push(mf); +}, 120_000); + +const registry: Miniflare[] = []; +afterAll(async () => { + for (const mf of registry) await mf.dispose(); +}); + +let seq = 0; +const NOW = () => new Date().toISOString(); + +async function seedProduct(inventory = 10, hasVariants = false) { + const id = `prod-fx-${++seq}`; + await db.insert(schema.products).values({ + id, name: `FX Product ${seq}`, handle: `fx-product-${seq}`, price: 1000, + hasVariants, inventory, trackInventory: true, lowStockThreshold: 2, + status: "ACTIVE", visibility: true, showInStore: true, storeFeatured: false, + createdAt: NOW(), updatedAt: NOW(), + }); + return id; +} + +async function seedVariant(productId: string, inventory = 5) { + const id = `var-fx-${++seq}`; + await db.insert(schema.productVariants).values({ + id, productId, variations: JSON.stringify({ اللون: "أسود" }), currency: "DZD", + price: 1200, sku: `SKU-FX-${seq}`, inventory, isDefault: true, active: true, + position: 0, createdAt: NOW(), updatedAt: NOW(), + }); + return id; +} + +async function seedCustomer() { + const id = `cust-fx-${++seq}`; + await db.insert(schema.customers).values({ + id, name: "FX Customer", phone: `0555${String(200000 + seq).slice(-6)}`, + wilaya: "الجزائر", totalOrders: 0, totalSpent: 0, createdAt: NOW(), + }); + return id; +} + +async function seedDriver() { + const id = `drv-fx-${++seq}`; + await db.insert(schema.drivers).values({ + id, firstName: "FX", lastName: "Driver", phone: "0666000001", + createdAt: NOW(), updatedAt: NOW(), + }); + return id; +} + +async function seedShippingProfile(rules: Array<{ wilayaId: number; home: number; homeEnabled?: number }>) { + // Reuse the single existing default profile (the schema allows only one + // meaningful default; creating several would make .get() pick arbitrarily). + let pid = (await db.select({ id: schema.shippingProfiles.id }) + .from(schema.shippingProfiles).where(eq(schema.shippingProfiles.isDefault, true)).get())?.id; + if (!pid) { + pid = `sp-fx-${++seq}`; + await db.insert(schema.shippingProfiles).values({ + id: pid, name: "FX Profile", isDefault: true, createdAt: NOW(), updatedAt: NOW(), + }); + } + for (const r of rules) { + await db.insert(schema.shippingRules).values({ + id: `sr-fx-${++seq}`, profileId: pid, wilayaId: r.wilayaId, + homePrice: r.home, stopDeskPrice: r.home, + homeEnabled: (r.homeEnabled ?? 1) === 1, stopDeskEnabled: true, + createdAt: NOW(), + }); + } + return pid; +} + +async function movementsFor(sku: { productId: string; variantId?: string | null }) { + const rows = await db.select().from(schema.stockMovements) + .where(sku.variantId + ? sql`${schema.stockMovements.productId} = ${sku.productId} AND ${schema.stockMovements.variantId} = ${sku.variantId}` + : eq(schema.stockMovements.productId, sku.productId)) + .all(); + return rows.sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id)); +} + +async function orderWith(productId: string, qty = 1, extra: Record = {}) { + const customerId = await seedCustomer(); + const oid = `ord-fx-${++seq}`; + await createOrder(db, { + id: oid, orderNumber: `ORD-FX-${seq}`, customerId, + customerName: "FX", phone: "0555000001", price: qty * 1000, + status: "new", deliveryMethod: "unassigned", deliveryType: "home", + deliveryFee: 0, driverFee: 0, codAmount: qty * 1000, createdAt: NOW(), updatedAt: NOW(), + ...extra, + }, [{ + id: `op-fx-${seq}`, orderId: oid, productId, productName: "FX", + quantity: qty, pricePerUnit: 1000, lineTotal: qty * 1000, createdAt: NOW(), + }], { id: "u1", name: "A" }); + return oid; +} + +// ───────────────────────────────────────────────────────────────────────────── + +describe("F1: storefront delivery fee — uncovered/disabled wilayas", () => { + // Each test uses its own wilaya: profiles seeded by earlier tests persist + // in this shared real DB and would otherwise pollute later expectations. + // This first test MUST run before any profile is seeded. + it("no shipping profile at all → fee 0 (matches dashboard semantics)", async () => { + const { getDeliveryFee } = await import("../../../../cod-shared/queries/store"); + expect(await getDeliveryFee(db, 16, "home")).toBe(0); + }); + + it("order to a wilaya with no rule is REFUSED (profile exists)", async () => { + const { getDeliveryFee } = await import("../../../../cod-shared/queries/store"); + await seedShippingProfile([{ wilayaId: 21, home: 600 }]); // only wilaya 21 covered + + const fee = await getDeliveryFee(db, 22, "home"); + // FIXED contract: null = unavailable — the handler must refuse the order + expect(fee).toBeNull(); + }); + + it("covered wilaya resolves its price", async () => { + const { getDeliveryFee } = await import("../../../../cod-shared/queries/store"); + await seedShippingProfile([{ wilayaId: 23, home: 600 }]); + + expect(await getDeliveryFee(db, 23, "home")).toBe(600); + }); + + it("disabled delivery type is refused even when the rule exists", async () => { + const { getDeliveryFee } = await import("../../../../cod-shared/queries/store"); + await seedShippingProfile([{ wilayaId: 24, home: 600, homeEnabled: 0 }]); + + expect(await getDeliveryFee(db, 24, "home")).toBeNull(); + expect(await getDeliveryFee(db, 24, "stop_desk")).toBe(600); + }); + + it("rule-less wilaya under an existing profile → unavailable", async () => { + const { getDeliveryFee } = await import("../../../../cod-shared/queries/store"); + expect(await getDeliveryFee(db, 25, "home")).toBeNull(); + }); +}); + +describe("F2: carrier amount sync keeps codAmount honest", () => { + it("syncing price recomputes codAmount = price + deliveryFee", async () => { + const productId = await seedProduct(); + const oid = await orderWith(productId, 1, { + price: 9000, codAmount: 9600, deliveryFee: 600, + }); + + await syncOrderAfterCarrierUpdate(db, oid, { price: 12000 }); + + const order = (await db.select().from(schema.orders).where(eq(schema.orders.id, oid)).get())!; + expect(order.price).toBe(12000); + // FIXED: the COD the driver is booked to collect follows the carrier amount + expect(Number(order.codAmount)).toBe(12600); + }); + + it("name-only sync leaves price and codAmount untouched", async () => { + const productId = await seedProduct(); + const oid = await orderWith(productId, 1, { price: 9000, codAmount: 9600, deliveryFee: 600 }); + + await syncOrderAfterCarrierUpdate(db, oid, { customerName: "New Name" }); + + const order = (await db.select().from(schema.orders).where(eq(schema.orders.id, oid)).get())!; + expect(order.price).toBe(9000); + expect(Number(order.codAmount)).toBe(9600); + expect(order.customerName).toBe("New Name"); + }); +}); + +describe("F3: catalog inventory edits write the ledger", () => { + it("variant inventory edit logs an ADJUSTMENT movement with the delta", async () => { + const productId = await seedProduct(0, true); + const variantId = await seedVariant(productId, 10); + + await updateVariant(db, variantId, { inventory: 15 }); + + const mv = await movementsFor({ productId, variantId }); + expect(mv).toHaveLength(1); + expect(mv[0]).toMatchObject({ + type: "ADJUSTMENT_ADD", delta: 5, qtyBefore: 10, qtyAfter: 15, + productId, variantId, + }); + const v = (await db.select().from(schema.productVariants).where(eq(schema.productVariants.id, variantId)).get())!; + expect(v.inventory).toBe(15); + }); + + it("variant inventory decrease logs ADJUSTMENT_REMOVE", async () => { + const productId = await seedProduct(0, true); + const variantId = await seedVariant(productId, 10); + + await updateVariant(db, variantId, { inventory: 4 }); + + const mv = await movementsFor({ productId, variantId }); + expect(mv[0]).toMatchObject({ type: "ADJUSTMENT_REMOVE", delta: -6, qtyBefore: 10, qtyAfter: 4 }); + }); + + it("variant edit without inventory change writes no movement", async () => { + const productId = await seedProduct(0, true); + const variantId = await seedVariant(productId, 10); + + await updateVariant(db, variantId, { price: 1500 }); + + expect(await movementsFor({ productId, variantId })).toHaveLength(0); + }); + + it("untracked product: no ledger rows even when inventory edited", async () => { + const productId = `prod-fx-${++seq}`; + await db.insert(schema.products).values({ + id: productId, name: "Untracked", handle: `untracked-${seq}`, price: 1000, + hasVariants: false, inventory: 10, trackInventory: false, lowStockThreshold: 2, + status: "ACTIVE", visibility: true, showInStore: true, storeFeatured: false, + createdAt: NOW(), updatedAt: NOW(), + }); + + await updateProduct(db, productId, { inventory: 15 }); + + expect(await movementsFor({ productId })).toHaveLength(0); + }); + + it("simple product inventory edit logs the movement", async () => { + const productId = await seedProduct(10); + + await updateProduct(db, productId, { inventory: 7 }); + + const mv = await movementsFor({ productId }); + expect(mv).toHaveLength(1); + expect(mv[0]).toMatchObject({ type: "ADJUSTMENT_REMOVE", delta: -3, qtyBefore: 10, qtyAfter: 7, variantId: null }); + }); + + it("variant create logs opening stock; delete exits with the row (cascade contract)", async () => { + const productId = await seedProduct(0, true); + + const variant = await createVariant(db, productId, { + variations: { "اللون": "أحمر" }, price: 1200, sku: `SKU-OPEN-${seq}`, + inventory: 8, isDefault: false, active: true, position: 1, + }); + let mv = await movementsFor({ productId, variantId: variant!.id }); + expect(mv).toHaveLength(1); + expect(mv[0]).toMatchObject({ type: "ADJUSTMENT_ADD", delta: 8, qtyBefore: 0, qtyAfter: 8 }); + + await deleteVariant(db, variant!.id); + + // stock_movements.variant_id is ON DELETE cascade: the variant's scoped + // movements leave with the row, keeping Σ(movements) reconciled to the + // pool (no phantom -8 at productId level against a vanished +8). + expect(await db.select().from(schema.productVariants).where(eq(schema.productVariants.id, variant!.id)).get()).toBeUndefined(); + expect(await movementsFor({ productId, variantId: variant!.id })).toHaveLength(0); + // And the exit is still atomic: order lines referencing it are nulled. + expect(await db.select().from(schema.orderProducts).where(eq(schema.orderProducts.variantId, variant!.id)).all()).toHaveLength(0); + }); +}); + +describe("F4: manual adjustStock — atomic and race-free", () => { + it("concurrent adjustments both land (no lost delta)", async () => { + const productId = await seedProduct(10); + + await Promise.allSettled([ + adjustStock(db, { productId, variantId: null, type: "ADJUSTMENT_ADD", delta: 5, reason: "r1", createdBy: "u1", createdByName: "A" }), + adjustStock(db, { productId, variantId: null, type: "ADJUSTMENT_ADD", delta: 3, reason: "r2", createdBy: "u2", createdByName: "B" }), + ]); + + const inv = (await db.select().from(schema.products).where(eq(schema.products.id, productId)).get())!; + expect(inv.inventory).toBe(18); // both deltas — no lost update + const mv = await movementsFor({ productId }); + expect(mv).toHaveLength(2); + expect(mv.reduce((s, m) => s + m.delta, 0)).toBe(8); + }); + + it("insufficient stock keeps the friendly 422 contract", async () => { + const productId = await seedProduct(2); + + await expect( + adjustStock(db, { productId, variantId: null, type: "ADJUSTMENT_REMOVE", delta: -5, reason: "r", createdBy: "u1", createdByName: "A" }), + ).rejects.toThrow(/Insufficient stock/); + + const inv = (await db.select().from(schema.products).where(eq(schema.products.id, productId)).get())!; + expect(inv.inventory).toBe(2); // untouched + expect(await movementsFor({ productId })).toHaveLength(0); + }); + + it("movement type must match delta direction", async () => { + const productId = await seedProduct(10); + + await expect( + adjustStock(db, { productId, variantId: null, type: "ADJUSTMENT_ADD", delta: -3, reason: "r", createdBy: "u1", createdByName: "A" }), + ).rejects.toThrow(/direction|type/i); + + expect(await movementsFor({ productId })).toHaveLength(0); + }); +}); + +describe("F5: driver cash reconciliation", () => { + it("reports pendingCash, pending order total, and zero drift when consistent", async () => { + const driverId = await seedDriver(); + const productId = await seedProduct(); + const oid = await orderWith(productId, 1, { driverId, driverFee: 500, codAmount: 10500, price: 10000 }); + await updateOrderStatus(db, oid, "delivered"); + + const driver = await getDriverById(db, driverId); + const rec = (driver as any).cashReconciliation; + expect(rec).toMatchObject({ pendingCash: 10500, pendingOrdersTotal: 10500, pendingOrdersCount: 1, drift: 0 }); + }); + + it("surfaces drift when the ledger is corrupted (legacy damage visible)", async () => { + const driverId = await seedDriver(); + const productId = await seedProduct(); + const oid = await orderWith(productId, 1, { driverId, driverFee: 500, codAmount: 10500, price: 10000 }); + await updateOrderStatus(db, oid, "delivered"); + + // Simulate historical drift (e.g. damage from the pre-fix delete bug) + await db.update(schema.drivers).set({ pendingCash: 15500 }).where(eq(schema.drivers.id, driverId)); + + const driver = await getDriverById(db, driverId); + const rec = (driver as any).cashReconciliation; + expect(rec).toMatchObject({ pendingCash: 15500, pendingOrdersTotal: 10500, pendingOrdersCount: 1, drift: 5000 }); + }); +}); diff --git a/cod-server/src/endpoints/orders/money-integrity-probes.e2e.test.ts b/cod-server/src/endpoints/orders/money-integrity-probes.e2e.test.ts new file mode 100644 index 0000000..d9e16e4 --- /dev/null +++ b/cod-server/src/endpoints/orders/money-integrity-probes.e2e.test.ts @@ -0,0 +1,287 @@ +/** + * Money & inventory integrity — real-D1 probe matrix (audit, red-first) + * + * Proves or refutes the four top audit findings against real migrations on + * real D1. No mocks for the flows; fault injection only for atomicity probes. + * + * Probes: + * P1 storefront price forging — client pricePerUnit vs variant catalog price + * P2 delete delivered order → driver pendingCash/totalEarnings reconciliation + * P3 dashboard createOrder oversell floor + concurrent double-sell race + * P4 driver settlement atomicity (insert → link → counters) + */ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { Miniflare } from "miniflare"; +import { readFileSync, readdirSync } from "node:fs"; +import { resolve } from "node:path"; +import { drizzle } from "drizzle-orm/d1"; +import { eq, sql } from "drizzle-orm"; +import * as schema from "@/db/schema"; +import type { AppDb } from "@/db"; +import { createOrder, updateOrderStatus, deleteOrder } from "../../../../cod-shared/queries/orders"; + +let db: AppDb; +let rawD1: D1Database; + +beforeAll(async () => { + const mf = new Miniflare({ + script: "export default { fetch() { return new Response('ok'); } }", + modules: true, + d1Databases: { DB: "test-db" }, + }); + rawD1 = await mf.getD1Database("DB"); + const dir = resolve(__dirname, "../../db/migrations"); + const preparedStatements: D1PreparedStatement[] = []; + for (const file of readdirSync(dir).filter((f) => f.endsWith(".sql")).sort()) { + const statements = readFileSync(`${dir}/${file}`, "utf8") + .split("--> statement-breakpoint") + .flatMap((s) => s.split(/;\s*\n/)) + .map((s) => s.replace(/;+\s*$/, "").trim()) + .filter((s) => s.replace(/--[^\n]*/g, "").trim().length > 0); + for (const statement of statements) { + preparedStatements.push(rawD1.prepare(statement)); + } + } + for (let i = 0; i < preparedStatements.length; i += 50) { + await rawD1.batch(preparedStatements.slice(i, i + 50)); + } + db = drizzle(rawD1 as unknown as D1Database, { schema }) as unknown as AppDb; + registry.push(mf); +}, 120_000); + +const registry: Miniflare[] = []; +afterAll(async () => { + for (const mf of registry) await mf.dispose(); +}); + +let seq = 0; +const NOW = () => new Date().toISOString(); + +async function seedSimpleProduct(inventory: number, price = 10000) { + const id = `prod-mi-${++seq}`; + const now = NOW(); + await db.insert(schema.products).values({ + id, name: `MI Product ${seq}`, handle: `mi-product-${seq}`, price, + hasVariants: false, inventory, trackInventory: true, lowStockThreshold: 2, + status: "ACTIVE", visibility: true, showInStore: true, storeFeatured: false, + createdAt: now, updatedAt: now, + }); + return id; +} + +async function seedCustomer() { + const id = `cust-mi-${++seq}`; + await db.insert(schema.customers).values({ + id, name: "MI Customer", phone: `0555${String(100000 + seq).slice(-6)}`, + wilaya: "الجزائر", totalOrders: 0, totalSpent: 0, createdAt: NOW(), + }); + return id; +} + +async function seedDriver() { + const id = `drv-mi-${++seq}`; + const now = NOW(); + await db.insert(schema.drivers).values({ + id, firstName: "MI", lastName: "Driver", phone: "0666000001", + createdAt: now, updatedAt: now, + }); + return id; +} + +function orderInsert(id: string, productId: string, qty: number, price: number, extra: Record = {}) { + const now = NOW(); + return { + id, orderNumber: `ORD-MI-${seq}`, customerId: `cust-live-${seq}`, + customerName: "MI", phone: "0555000001", price, + status: "new" as const, deliveryMethod: "unassigned" as const, deliveryType: "home" as const, + deliveryFee: 0, driverFee: 0, codAmount: price, createdAt: now, updatedAt: now, + ...extra, + }; +} + +// ───────────────────────────────────────────────────────────────────────────── + +describe("P3: dashboard createOrder — oversell guard & race", () => { + it("oversell is rejected atomically — no order, no partial deduction", async () => { + const productId = await seedSimpleProduct(2); + const customerId = await seedCustomer(); + const oid = `ord-mi-${++seq}`; + + // qty 5 against stock 2 → the whole order must fail and roll back + const res = await createOrder(db, orderInsert(oid, productId, 0, 10000, { customerId }), [{ + id: `op-${seq}`, orderId: oid, productId, productName: "MI", + quantity: 5, pricePerUnit: 2000, lineTotal: 10000, createdAt: NOW(), + }]).then(() => "ok", (e: Error) => e); + + expect(res).toBeInstanceOf(Error); + // Nothing was written: no order, no lines, stats untouched, stock intact + expect(await db.select().from(schema.orders).where(eq(schema.orders.id, oid)).get()).toBeUndefined(); + expect(await db.select().from(schema.orderProducts).where(eq(schema.orderProducts.orderId, oid)).all()).toHaveLength(0); + expect(await db.select().from(schema.stockMovements).where(eq(schema.stockMovements.productId, productId)).all()).toHaveLength(0); + const cust = (await db.select().from(schema.customers).where(eq(schema.customers.id, customerId)).get())!; + expect(cust.totalOrders).toBe(0); + const inv = (await db.select().from(schema.products).where(eq(schema.products.id, productId)).get())!; + expect(inv.inventory).toBe(2); + }); + + it("race: two concurrent orders for the last unit — exactly one wins, no corruption", async () => { + const productId = await seedSimpleProduct(1); + const c1 = await seedCustomer(); + const c2 = await seedCustomer(); + const o1 = `ord-mi-${++seq}`; + const o2 = `ord-mi-${++seq}`; + const now = NOW(); + + const results = await Promise.allSettled([ + createOrder(db, { ...orderInsert(o1, productId, 0, 10000, { customerId: c1 }), orderNumber: `ORD-MI-${seq}-1` }, [{ + id: `op-${seq}a`, orderId: o1, productId, productName: "MI", quantity: 1, pricePerUnit: 10000, lineTotal: 10000, createdAt: now, + }]), + createOrder(db, { ...orderInsert(o2, productId, 0, 10000, { customerId: c2 }), orderNumber: `ORD-MI-${seq}-2` }, [{ + id: `op-${seq}b`, orderId: o2, productId, productName: "MI", quantity: 1, pricePerUnit: 10000, lineTotal: 10000, createdAt: now, + }]), + ]); + + const fulfilled = results.filter((r) => r.status === "fulfilled").length; + const rejected = results.filter((r) => r.status === "rejected").length; + + // FIXED: exactly one order commits, the other fails cleanly + expect(fulfilled).toBe(1); + expect(rejected).toBe(1); + + const inv = (await db.select().from(schema.products).where(eq(schema.products.id, productId)).get())!; + const movements = await db.select().from(schema.stockMovements).where(eq(schema.stockMovements.productId, productId)).all(); + expect(inv.inventory).toBe(0); + expect(movements).toHaveLength(1); + expect(movements[0].delta).toBe(-1); + expect(movements[0].qtyBefore).toBe(1); + expect(movements[0].qtyAfter).toBe(0); + }); +}); + +describe("P2: delete delivered order → driver money reconciliation", () => { + it("driver credit is fully reversed when deleting an unsettled delivered order", async () => { + const driverId = await seedDriver(); + const productId = await seedSimpleProduct(5); + const customerId = await seedCustomer(); + const oid = `ord-mi-${++seq}`; + + await createOrder(db, { ...orderInsert(oid, productId, 0, 10000, { customerId, driverId, driverFee: 500, codAmount: 10500 }), orderNumber: `ORD-MI-${seq}` }, [{ + id: `op-${seq}`, orderId: oid, productId, productName: "MI", quantity: 1, pricePerUnit: 10000, lineTotal: 10000, createdAt: NOW(), + }]); + await updateOrderStatus(db, oid, "delivered"); + + const drvBefore = (await db.select().from(schema.drivers).where(eq(schema.drivers.id, driverId)).get())!; + expect(drvBefore).toMatchObject({ totalDelivered: 1, totalEarnings: 500, pendingCash: 10500 }); + + await deleteOrder(db, oid); + + // FIXED: the driver ledger reconciles — no phantom cash, no ghost credit + const drvAfter = (await db.select().from(schema.drivers).where(eq(schema.drivers.id, driverId)).get())!; + expect(drvAfter).toMatchObject({ totalDelivered: 0, totalEarnings: 0, pendingCash: 0 }); + }); + + it("settled delivered order: driver counters untouched (payment history preserved)", async () => { + const driverId = await seedDriver(); + const productId = await seedSimpleProduct(5); + const customerId = await seedCustomer(); + const oid = `ord-mi-${++seq}`; + + await createOrder(db, { ...orderInsert(oid, productId, 0, 10000, { customerId, driverId, driverFee: 500, codAmount: 10500 }), orderNumber: `ORD-MI-${seq}` }, [{ + id: `op-${seq}`, orderId: oid, productId, productName: "MI", quantity: 1, pricePerUnit: 10000, lineTotal: 10000, createdAt: NOW(), + }]); + await updateOrderStatus(db, oid, "delivered"); + // Simulate an existing settlement link (append-only history) + await db.update(schema.orders).set({ codPaymentId: "dp-hist-1" }).where(eq(schema.orders.id, oid)); + + await deleteOrder(db, oid); + + const drvAfter = (await db.select().from(schema.drivers).where(eq(schema.drivers.id, driverId)).get())!; + // Counters stay — the money really moved; the payment row stays reconciled + expect(drvAfter).toMatchObject({ totalDelivered: 1, totalEarnings: 500, pendingCash: 10500 }); + }); +}); + +describe("P4: driver settlement atomicity (real createDriverPayment)", () => { + it("settles atomically: payment row + links + counters in one batch", async () => { + const { createDriverPayment } = await import("../driver-payments/queries"); + const driverId = await seedDriver(); + const productId = await seedSimpleProduct(5); + const customerId = await seedCustomer(); + const oid = `ord-mi-${++seq}`; + await createOrder(db, { ...orderInsert(oid, productId, 0, 10000, { customerId, driverId, driverFee: 500, codAmount: 10500 }), orderNumber: `ORD-MI-${seq}` }, [{ + id: `op-${seq}`, orderId: oid, productId, productName: "MI", quantity: 1, pricePerUnit: 10000, lineTotal: 10000, createdAt: NOW(), + }]); + await updateOrderStatus(db, oid, "delivered"); + + const res = await createDriverPayment( + db as any, + { driverId, type: "cod_remittance", orderIds: [oid], notes: "probe" }, + "u1", "Admin", + ); + + expect(res.amount).toBe(10500); + const drv = (await db.select().from(schema.drivers).where(eq(schema.drivers.id, driverId)).get())!; + expect(drv).toMatchObject({ pendingCash: 0, totalPaid: 10500, totalEarnings: 500 }); + const order = (await db.select().from(schema.orders).where(eq(schema.orders.id, oid)).get())!; + expect(order.codPaymentId).toBe(res.id); + const payments = await db.select().from(schema.driverPayments).all(); + expect(payments.filter((p) => p.driverId === driverId)).toHaveLength(1); + + // Double-settlement guard still enforced + await expect( + createDriverPayment(db as any, { driverId, type: "cod_remittance", orderIds: [oid], notes: undefined }, "u1", "Admin"), + ).rejects.toThrow(/already have their COD settled/); + }); +}); + +describe("P1: storefront price forging (server-authoritative pricing)", () => { + it("forged pricePerUnit is ignored — catalog price is charged", async () => { + const t = await import("../../../../cod-shared/queries/store"); + const productId = await seedSimpleProduct(10, 50000); // catalog: 50 000 DZD + const productName = "MI Forged Probe"; + + const customerId = await t.findOrCreateCustomer(db, { + phone: "0770000001", name: "Forged", wilayaId: 16, communeId: "c-16-001", + }); + + const result = await t.createStoreOrder(db, { + productId, + productName, + quantity: 1, + pricePerUnit: 1, // forged: 1 DZD + customerName: "Forged", + phone: "0770000001", + wilayaId: 16, + communeId: "c-16-001", + address: "x", + deliveryType: "home", + notes: null, + variantId: null, + variantLabel: null, + offerId: null, + variantSelections: [], + customerId: typeof customerId === "string" ? customerId : (customerId as any).id, + deliveryFee: 0, + } as any).catch((e: Error) => e); + + expect(result).not.toBeInstanceOf(Error); + + const created = await db.select().from(schema.orders).where(eq(schema.orders.customerId, typeof customerId === "string" ? customerId : (customerId as any).id)).all(); + const forged = created.find((o) => o.phone === "0770000001"); + expect(forged).toBeDefined(); + // FIXED: the order persisted at the CATALOG price, not the forged 1 DZD + expect(Number(forged!.price)).toBe(50000); + expect(Number(forged!.codAmount)).toBe(50000); + + // And the customer stats inherited the real price, not the forged one + const cust = (await db.select().from(schema.customers) + .where(eq(schema.customers.id, typeof customerId === "string" ? customerId : (customerId as any).id)).get())!; + expect(Number(cust.totalSpent)).toBe(50000); + + // The line too + const line = (await db.select().from(schema.orderProducts) + .where(eq(schema.orderProducts.orderId, forged!.id)).get())!; + expect(line.pricePerUnit).toBe(50000); + expect(line.lineTotal).toBe(50000); + }); +}); diff --git a/cod-server/src/endpoints/orders/orders.delete-e2e.test.ts b/cod-server/src/endpoints/orders/orders.delete-e2e.test.ts new file mode 100644 index 0000000..069f412 --- /dev/null +++ b/cod-server/src/endpoints/orders/orders.delete-e2e.test.ts @@ -0,0 +1,132 @@ +/** + * deleteOrder — real D1 (E2E) + * + * Regression: deleting an order that has carrier-API audit rows + * (company_api_logs) or inbound webhook rows (webhook_events) failed with + * FOREIGN KEY constraint failed — both tables reference orders(id) with + * ON DELETE no action and were not cleaned up by deleteOrder. Every + * dispatched order creates company_api_logs rows, so deletion failed for + * practically every real order (D1 enforces FKs; the mock-db unit tests + * cannot catch this). + */ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { Miniflare } from "miniflare"; +import { readFileSync, readdirSync } from "node:fs"; +import { resolve } from "node:path"; +import { drizzle } from "drizzle-orm/d1"; +import { eq } from "drizzle-orm"; +import * as schema from "@/db/schema"; +import type { AppDb } from "@/db"; +import { deleteOrder } from "../../../../cod-shared/queries/orders"; + +let db: AppDb; + +beforeAll(async () => { + const mf = new Miniflare({ + script: "export default { fetch() { return new Response('ok'); } }", + modules: true, + d1Databases: { DB: "test-db" }, + }); + const d1 = await mf.getD1Database("DB"); + const dir = resolve(__dirname, "../../db/migrations"); + const preparedStatements: D1PreparedStatement[] = []; + for (const file of readdirSync(dir).filter((f) => f.endsWith(".sql")).sort()) { + const statements = readFileSync(`${dir}/${file}`, "utf8") + .split("--> statement-breakpoint") + .flatMap((s) => s.split(/;\s*\n/)) + .map((s) => s.replace(/;+\s*$/, "").trim()) + .filter((s) => s.replace(/--[^\n]*/g, "").trim().length > 0); + for (const statement of statements) { + preparedStatements.push(d1.prepare(statement)); + } + } + for (let i = 0; i < preparedStatements.length; i += 50) { + await d1.batch(preparedStatements.slice(i, i + 50)); + } + db = drizzle(d1 as unknown as D1Database, { schema }) as unknown as AppDb; + registry.push(mf); +}, 120_000); + +const registry: Miniflare[] = []; + +afterAll(async () => { + for (const mf of registry) await mf.dispose(); +}); + +async function seedOrder(id: string, withCarrierActivity: boolean) { + const now = new Date().toISOString(); + await db.insert(schema.customers).values({ + id: `cust-${id}`, name: "E2E Customer", phone: `0555${id.slice(-6)}`, + wilaya: "الجزائر", totalOrders: 1, totalSpent: 2000, createdAt: now, + }); + await db.insert(schema.orders).values({ + id, orderNumber: `ORD-${id}`, customerId: `cust-${id}`, + customerName: "E2E Customer", phone: "0555000001", price: 2000, + status: "delivered", deliveryMethod: "company", deliveryType: "home", + deliveryFee: 0, driverFee: 0, codAmount: 2000, createdAt: now, updatedAt: now, + }); + await db.insert(schema.orderProducts).values({ + id: `op-${id}`, orderId: id, productId: "prod-e2e-del", productName: "E2E Product", + quantity: 2, pricePerUnit: 1000, lineTotal: 2000, createdAt: now, + }); + await db.insert(schema.orderStatusHistory).values({ + id: `hist-${id}`, orderId: id, status: "delivered", timestamp: now, by: null, + }); + + if (withCarrierActivity) { + await db.insert(schema.deliveryCompanies).values({ + id: "comp-e2e-del", name: "E2E Carrier", nameAr: "E2E Carrier", code: "e2e-carrier", + active: true, createdAt: now, updatedAt: now, + }); + await db.insert(schema.companyShipments).values({ + id: `ship-${id}`, orderId: id, companyId: "comp-e2e-del", + trackingNumber: `TRK-${id}`, createdAt: now, updatedAt: now, + }); + await db.insert(schema.companyApiLogs).values({ + id: `log-${id}`, companyId: "comp-e2e-del", orderId: id, + action: "create_shipment", method: "POST", endpoint: "/api/public/create/order", + createdAt: now, + }); + await db.insert(schema.webhookEvents).values({ + id: `evt-${id}`, provider: "zr_express", eventId: `evt-${id}`, + companyId: "comp-e2e-del", orderId: id, tracking: `TRK-${id}`, + eventType: "parcel_status_updated", rawPayload: "{}", result: "ok", + createdAt: now, + }); + } +} + +describe("deleteOrder — real D1 (E2E)", () => { + beforeAll(async () => { + const now = new Date().toISOString(); + await db.insert(schema.products).values({ + id: "prod-e2e-del", name: "E2E Product", handle: "e2e-product-del", price: 1000, + hasVariants: false, inventory: 5, trackInventory: true, lowStockThreshold: 2, + status: "ACTIVE", visibility: true, showInStore: true, storeFeatured: false, + createdAt: now, updatedAt: now, + }); + }); + + it("deletes a dispatched order with api logs and webhook events", async () => { + await seedOrder("ord-e2e-del-1", true); + + await deleteOrder(db, "ord-e2e-del-1"); + + expect(await db.select().from(schema.orders).where(eq(schema.orders.id, "ord-e2e-del-1")).get()).toBeUndefined(); + + expect(await db.select().from(schema.orders).where(eq(schema.orders.id, "ord-e2e-del-1")).get()).toBeUndefined(); + expect(await db.select().from(schema.orderProducts).where(eq(schema.orderProducts.orderId, "ord-e2e-del-1")).all()).toHaveLength(0); + expect(await db.select().from(schema.companyShipments).where(eq(schema.companyShipments.orderId, "ord-e2e-del-1")).all()).toHaveLength(0); + expect(await db.select().from(schema.orderStatusHistory).where(eq(schema.orderStatusHistory.orderId, "ord-e2e-del-1")).all()).toHaveLength(0); + expect(await db.select().from(schema.companyApiLogs).where(eq(schema.companyApiLogs.orderId, "ord-e2e-del-1")).all()).toHaveLength(0); + expect(await db.select().from(schema.webhookEvents).where(eq(schema.webhookEvents.orderId, "ord-e2e-del-1")).all()).toHaveLength(0); + }); + + it("deletes an order with no carrier activity", async () => { + await seedOrder("ord-e2e-del-2", false); + + await deleteOrder(db, "ord-e2e-del-2"); + + expect(await db.select().from(schema.orders).where(eq(schema.orders.id, "ord-e2e-del-2")).get()).toBeUndefined(); + }); +}); diff --git a/cod-server/src/endpoints/orders/orders.keyset-e2e.test.ts b/cod-server/src/endpoints/orders/orders.keyset-e2e.test.ts index f9b192d..0bf4f9e 100644 --- a/cod-server/src/endpoints/orders/orders.keyset-e2e.test.ts +++ b/cod-server/src/endpoints/orders/orders.keyset-e2e.test.ts @@ -42,6 +42,7 @@ beforeAll(async () => { registry.push(mf); const d1 = await mf.getD1Database("DB"); const dir = resolve(__dirname, "../../db/migrations"); + const preparedStatements: D1PreparedStatement[] = []; for (const file of readdirSync(dir).filter((f) => f.endsWith(".sql")).sort()) { const statements = readFileSync(`${dir}/${file}`, "utf8") .split("--> statement-breakpoint") @@ -49,9 +50,12 @@ beforeAll(async () => { .map((s) => s.replace(/;+\s*$/, "").trim()) .filter((s) => s.replace(/--[^\n]*/g, "").trim().length > 0); for (const statement of statements) { - await d1.prepare(statement).run(); + preparedStatements.push(d1.prepare(statement)); } } + for (let i = 0; i < preparedStatements.length; i += 50) { + await d1.batch(preparedStatements.slice(i, i + 50)); + } db = drizzle(d1 as unknown as D1Database, { schema }) as unknown as AppDb; // 25 orders over only 5 distinct timestamps — 5-way ties everywhere, the diff --git a/cod-server/src/endpoints/orders/orders.read-e2e.test.ts b/cod-server/src/endpoints/orders/orders.read-e2e.test.ts index da9f28c..c33abb7 100644 --- a/cod-server/src/endpoints/orders/orders.read-e2e.test.ts +++ b/cod-server/src/endpoints/orders/orders.read-e2e.test.ts @@ -34,6 +34,7 @@ beforeAll(async () => { registry.push(mf); const d1 = await mf.getD1Database("DB"); const dir = resolve(__dirname, "../../db/migrations"); + const preparedStatements: D1PreparedStatement[] = []; for (const file of readdirSync(dir).filter((f) => f.endsWith(".sql")).sort()) { const statements = readFileSync(`${dir}/${file}`, "utf8") .split("--> statement-breakpoint") @@ -41,9 +42,12 @@ beforeAll(async () => { .map((s) => s.replace(/;+\s*$/, "").trim()) .filter((s) => s.replace(/--[^\n]*/g, "").trim().length > 0); for (const statement of statements) { - await d1.prepare(statement).run(); + preparedStatements.push(d1.prepare(statement)); } } + for (let i = 0; i < preparedStatements.length; i += 50) { + await d1.batch(preparedStatements.slice(i, i + 50)); + } db = drizzle(d1 as unknown as D1Database, { schema }) as unknown as AppDb; const commune = await db diff --git a/cod-server/src/endpoints/orders/orders.stock-lifecycle-e2e.test.ts b/cod-server/src/endpoints/orders/orders.stock-lifecycle-e2e.test.ts new file mode 100644 index 0000000..b4b7ddc --- /dev/null +++ b/cod-server/src/endpoints/orders/orders.stock-lifecycle-e2e.test.ts @@ -0,0 +1,431 @@ +/** + * Stock & customer-stats lifecycle — real D1 (E2E diagnosis matrix) + * + * Runs every restock/stats path against real migrations on real D1 (FKs + * enforced, exactly like production). No mocks for the flows themselves. + * + * Invariants under test (senior-dev checklist): + * I1 inventory = initial - sold + restocked-from-terminal (never double) + * I2 customer.totalOrders tracks non-deleted orders + * I3 customer.totalSpent = spend of non-terminal, non-deleted orders + * I4 driver credit (delivered/earnings/pendingCash) applied exactly once + * I5 every multi-write flow is atomic — failure leaves NO partial state + * I6 stock_movements ledger deltas sum to the real inventory change + * + * Fault injection (makeFaultyDb) wraps the REAL d1 and rejects a chosen + * write — proving atomicity with real SQL rather than mocks. + */ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { Miniflare } from "miniflare"; +import { readFileSync, readdirSync } from "node:fs"; +import { resolve } from "node:path"; +import { drizzle } from "drizzle-orm/d1"; +import { eq } from "drizzle-orm"; +import * as schema from "@/db/schema"; +import type { AppDb } from "@/db"; +import { + createOrder, + updateOrderStatus, + updateOrderStatusWebhook, + setOrderProductReturn, + deleteOrder, +} from "../../../../cod-shared/queries/orders"; + +let db: AppDb; +let rawD1: D1Database; + +beforeAll(async () => { + const mf = new Miniflare({ + script: "export default { fetch() { return new Response('ok'); } }", + modules: true, + d1Databases: { DB: "test-db" }, + }); + rawD1 = await mf.getD1Database("DB"); + const dir = resolve(__dirname, "../../db/migrations"); + const preparedStatements: D1PreparedStatement[] = []; + for (const file of readdirSync(dir).filter((f) => f.endsWith(".sql")).sort()) { + const statements = readFileSync(`${dir}/${file}`, "utf8") + .split("--> statement-breakpoint") + .flatMap((s) => s.split(/;\s*\n/)) + .map((s) => s.replace(/;+\s*$/, "").trim()) + .filter((s) => s.replace(/--[^\n]*/g, "").trim().length > 0); + for (const statement of statements) { + preparedStatements.push(rawD1.prepare(statement)); + } + } + for (let i = 0; i < preparedStatements.length; i += 50) { + await rawD1.batch(preparedStatements.slice(i, i + 50)); + } + db = drizzle(rawD1 as unknown as D1Database, { schema }) as unknown as AppDb; + registry.push(mf); +}, 120_000); + +const registry: Miniflare[] = []; + +afterAll(async () => { + for (const mf of registry) await mf.dispose(); +}); + +// ─── Fault injection: real D1 + rejection at a chosen write ─────────────────── + +function makeFaultyDb(failWhen: (sqlText: string) => boolean): AppDb { + const poisoned = () => { + const stub: Record = { + __sql: "POISONED", + bind: () => stub, + run: () => Promise.reject(new Error("INJECTED FAULT")), + raw: () => Promise.reject(new Error("INJECTED FAULT")), + all: () => Promise.reject(new Error("INJECTED FAULT")), + get: () => Promise.reject(new Error("INJECTED FAULT")), + first: () => Promise.reject(new Error("INJECTED FAULT")), + }; + return stub as unknown as D1PreparedStatement; + }; + + const faulty = { + prepare: (sqlText: string) => { + const stmt = rawD1.prepare(sqlText); + if (failWhen(sqlText)) return poisoned(); + (stmt as D1PreparedStatement & { __sql?: string }).__sql = sqlText; + return stmt; + }, + batch: (stmts: D1PreparedStatement[]) => { + for (const stmt of stmts) { + const sqlText = (stmt as D1PreparedStatement & { __sql?: string }).__sql; + // Poisoned stmts (prepared while faulting) or a matching statement + // fault the whole batch — D1 batches are atomic, mirroring reality. + if (sqlText === "POISONED" || (sqlText && failWhen(sqlText))) { + return Promise.reject(new Error("INJECTED FAULT")); + } + } + return rawD1.batch(stmts); + }, + exec: (sqlText: string) => rawD1.exec(sqlText), + dump: () => rawD1.dump(), + } as unknown as D1Database; + + return drizzle(faulty, { schema }) as unknown as AppDb; +} + +// ─── Fixtures ───────────────────────────────────────────────────────────────── + +let seq = 0; + +const INITIAL_INVENTORY = 10; + +async function seedCustomer(baseOrders = 0, baseSpent = 0) { + const id = `cust-lc-${++seq}`; + const now = new Date().toISOString(); + await db.insert(schema.customers).values({ + id, name: "LC Customer", phone: `0555${String(100000 + seq).slice(-6)}`, + wilaya: "الجزائر", totalOrders: baseOrders, totalSpent: baseSpent, + createdAt: now, + }); + return id; +} + +async function seedProduct(inventory = INITIAL_INVENTORY) { + const id = `prod-lc-${++seq}`; + const now = new Date().toISOString(); + await db.insert(schema.products).values({ + id, name: "LC Product", handle: `lc-product-${seq}`, price: 1000, + hasVariants: false, inventory, trackInventory: true, lowStockThreshold: 2, + status: "ACTIVE", visibility: true, showInStore: true, storeFeatured: false, + createdAt: now, updatedAt: now, + }); + return id; +} + +interface SeedOrderOpts { + qty?: number; + price?: number; + driverId?: string; + driverFee?: number; + codAmount?: number; +} + +async function seedOrder(opts: SeedOrderOpts = {}) { + const { qty = 2, price = 2000, driverId, driverFee = 350, codAmount = 2600 } = opts; + const customerId = await seedCustomer(); + const productId = await seedProduct(); + const id = `ord-lc-${++seq}`; + const now = new Date().toISOString(); + + await createOrder( + db, + { + id, orderNumber: `ORD-LC-${seq}`, customerId, + customerName: "LC Customer", phone: "0555000001", price, + status: "new", deliveryMethod: driverId ? "driver" : "unassigned", + deliveryType: "home", deliveryFee: 0, driverFee: driverId ? driverFee : 0, + codAmount, driverId: driverId ?? null, + createdAt: now, updatedAt: now, + }, + [{ + id: `op-lc-${seq}`, orderId: id, productId, productName: "LC Product", + quantity: qty, pricePerUnit: price / qty, lineTotal: price, createdAt: now, + }], + { id: "user_1", name: "Admin" }, + ); + + return { orderId: id, customerId, productId, lineId: `op-lc-${seq}` }; +} + +async function inventoryOf(productId: string) { + return (await db.select({ inv: schema.products.inventory }).from(schema.products) + .where(eq(schema.products.id, productId)).get())?.inv; +} + +async function customerOf(customerId: string) { + return (await db.select().from(schema.customers) + .where(eq(schema.customers.id, customerId)).get())!; +} + +async function lineOf(lineId: string) { + return (await db.select().from(schema.orderProducts) + .where(eq(schema.orderProducts.id, lineId)).get())!; +} + +async function orderStatus(orderId: string) { + return (await db.select({ s: schema.orders.status }).from(schema.orders) + .where(eq(schema.orders.id, orderId)).get())?.s; +} + +async function movementsFor(productId: string) { + const rows = await db.select().from(schema.stockMovements) + .where(eq(schema.stockMovements.productId, productId)).all(); + // createdAt ties (same-ms ISO) can order arbitrarily — sort, then fall back + // to numeric-string compare on id for determinism. + return rows.sort((a, b) => + a.createdAt === b.createdAt ? a.id.localeCompare(b.id) : a.createdAt.localeCompare(b.createdAt), + ); +} + +async function seedDriver() { + const id = `drv-lc-${++seq}`; + const now = new Date().toISOString(); + await db.insert(schema.drivers).values({ + id, firstName: "LC", lastName: "Driver", phone: "0666000001", + createdAt: now, updatedAt: now, + }); + return id; +} + +// ───────────────────────────────────────────────────────────────────────────── +// The matrix +// ───────────────────────────────────────────────────────────────────────────── + +describe("stock & customer stats — real D1 lifecycle matrix", () => { + it("T1: createOrder deducts stock, bumps stats, logs ledger (baseline)", async () => { + const { orderId, productId, customerId } = await seedOrder(); + + expect(await orderStatus(orderId)).toBe("new"); + expect(await inventoryOf(productId)).toBe(INITIAL_INVENTORY - 2); // I1 + expect(await customerOf(customerId)).toMatchObject({ totalOrders: 1, totalSpent: 2000 }); // I2/I3 + const mv = await movementsFor(productId); + expect(mv).toHaveLength(1); + expect(mv[0]).toMatchObject({ type: "ORDER_DEDUCTED", delta: -2, qtyBefore: 10, qtyAfter: 8 }); // I6 + }); + + it("T2: manual cancel restocks, rolls back totalSpent, marks lines returned", async () => { + const { orderId, productId, customerId, lineId } = await seedOrder(); + + await updateOrderStatus(db, orderId, "cancelled"); + + expect(await inventoryOf(productId)).toBe(INITIAL_INVENTORY); // I1 + expect(await customerOf(customerId)).toMatchObject({ totalSpent: 0 }); // I3 + expect(await lineOf(lineId)).toMatchObject({ status: "returned", returnedQuantity: 2 }); + const mv = await movementsFor(productId); + expect(mv.map((m) => m.delta)).toEqual([-2, 2]); // I6 + }); + + it("T3: double manual cancel — wasAlreadyTerminal guard prevents double restock", async () => { + const { orderId, productId } = await seedOrder(); + + await updateOrderStatus(db, orderId, "cancelled"); + await updateOrderStatus(db, orderId, "cancelled"); + + expect(await inventoryOf(productId)).toBe(INITIAL_INVENTORY); // I1 + expect((await movementsFor(productId)).map((m) => m.delta)).toEqual([-2, 2]); + }); + + it("T4: webhook cancel after manual cancel — rank guard prevents double restock/stats", async () => { + const { orderId, productId, customerId } = await seedOrder(); + + await updateOrderStatus(db, orderId, "cancelled"); + const res = await updateOrderStatusWebhook(db, orderId, "cancelled", "e2e"); + + expect(res).toEqual({ updated: false }); + expect(await inventoryOf(productId)).toBe(INITIAL_INVENTORY); + expect(await customerOf(customerId)).toMatchObject({ totalSpent: 0 }); + expect((await movementsFor(productId)).map((m) => m.delta)).toEqual([-2, 2]); + }); + + it("T5: partial return then cancel — restock totals exactly the sold quantity", async () => { + const { orderId, productId, lineId } = await seedOrder({ qty: 4, price: 4000 }); + + await setOrderProductReturn(db, orderId, lineId, 1); + expect(await inventoryOf(productId)).toBe(INITIAL_INVENTORY - 3); + + await updateOrderStatus(db, orderId, "cancelled"); + expect(await inventoryOf(productId)).toBe(INITIAL_INVENTORY); // I1 + expect(await lineOf(lineId)).toMatchObject({ status: "returned", returnedQuantity: 4 }); + expect((await movementsFor(productId)).map((m) => m.delta)).toEqual([-4, 1, 3]); // I6 + }); + + it("T6: return correction (2 → 1) on active order — inventory follows the delta", async () => { + const { orderId, productId, lineId } = await seedOrder({ qty: 4, price: 4000 }); + + await setOrderProductReturn(db, orderId, lineId, 2); + expect(await inventoryOf(productId)).toBe(INITIAL_INVENTORY - 2); + + await setOrderProductReturn(db, orderId, lineId, 1); + expect(await inventoryOf(productId)).toBe(INITIAL_INVENTORY - 3); // I1 + expect(await lineOf(lineId)).toMatchObject({ status: "partially_returned", returnedQuantity: 1 }); + expect((await movementsFor(productId)).map((m) => m.delta)).toEqual([-4, 2, -1]); // I6 + }); + + it("T7: manual delivered with driver — driver credited exactly once", async () => { + const driverId = await seedDriver(); + const { orderId } = await seedOrder({ driverId, driverFee: 350, codAmount: 2600 }); + + await updateOrderStatus(db, orderId, "delivered"); + + const drv = (await db.select().from(schema.drivers) + .where(eq(schema.drivers.id, driverId)).get())!; + expect(drv).toMatchObject({ totalDelivered: 1, totalEarnings: 350, pendingCash: 2600 }); // I4 + }); + + it("T8: webhook delivered after manual delivered — rank guard, credit stays once", async () => { + const driverId = await seedDriver(); + const { orderId } = await seedOrder({ driverId }); + + await updateOrderStatus(db, orderId, "delivered"); + const res = await updateOrderStatusWebhook(db, orderId, "delivered", "e2e"); + + expect(res).toEqual({ updated: false }); + const drv = (await db.select().from(schema.drivers) + .where(eq(schema.drivers.id, driverId)).get())!; + expect(drv).toMatchObject({ totalDelivered: 1, totalEarnings: 350, pendingCash: 2600 }); // I4 + }); + + it("T9: deleteOrder on an active order — stats and stock fully rolled back", async () => { + const { orderId, productId, customerId } = await seedOrder(); + + await deleteOrder(db, orderId); + + expect(await inventoryOf(productId)).toBe(INITIAL_INVENTORY); // I1 + expect(await customerOf(customerId)).toMatchObject({ totalOrders: 0, totalSpent: 0 }); // I2/I3 + expect(await db.select().from(schema.orders).where(eq(schema.orders.id, orderId)).get()).toBeUndefined(); + }); + + it("T10: cancel THEN delete — customer stats must return to the pre-order baseline", async () => { + // Customer with PRIOR spend so the MAX(0, …) floor cannot mask a + // double-decrement (the floor silently absorbs it at totalSpent=0). + const customerId = await seedCustomer(1, 5000); + const productId = await seedProduct(); + const orderId = `ord-lc-${++seq}`; + const now = new Date().toISOString(); + await createOrder(db, { + id: orderId, orderNumber: `ORD-LC-${seq}`, customerId, + customerName: "LC Customer", phone: "0555000001", price: 2000, + status: "new", deliveryMethod: "unassigned", deliveryType: "home", + deliveryFee: 0, driverFee: 0, codAmount: 2000, createdAt: now, updatedAt: now, + }, [{ + id: `op-lc-${seq}`, orderId, productId, productName: "LC Product", + quantity: 2, pricePerUnit: 1000, lineTotal: 2000, createdAt: now, + }], { id: "u1", name: "A" }); + + await updateOrderStatus(db, orderId, "cancelled"); // totalSpent 7000 → 5000 + await deleteOrder(db, orderId); + + // I2/I3: the order is gone; its cancelled spend was already rolled back + // at cancel time. Deleting must not subtract it a second time. + expect(await customerOf(customerId)).toMatchObject({ totalOrders: 1, totalSpent: 5000 }); + expect(await inventoryOf(productId)).toBe(INITIAL_INVENTORY); + }); + + it("T11: oversell (qty > inventory) is rejected atomically — no partial state", async () => { + const productId = await seedProduct(1); // 1 unit in stock + const customerId = await seedCustomer(); + const orderId = `ord-lc-${++seq}`; + const now = new Date().toISOString(); + + // qty 2 against 1 unit: the guarded deduction makes the whole batch fail + await expect(createOrder(db, { + id: orderId, orderNumber: `ORD-LC-${seq}`, customerId, + customerName: "LC Customer", phone: "0555000001", price: 2000, + status: "new", deliveryMethod: "unassigned", deliveryType: "home", + deliveryFee: 0, driverFee: 0, codAmount: 2000, createdAt: now, updatedAt: now, + }, [{ + id: `op-lc-${seq}`, orderId, productId, productName: "LC Product", + quantity: 2, pricePerUnit: 1000, lineTotal: 2000, createdAt: now, + }], { id: "u1", name: "A" })).rejects.toThrow(); + + // Nothing committed: no order, no lines, stats untouched, stock intact + expect(await db.select().from(schema.orders).where(eq(schema.orders.id, orderId)).get()).toBeUndefined(); + expect(await customerOf(customerId)).toMatchObject({ totalOrders: 0, totalSpent: 0 }); + expect(await inventoryOf(productId)).toBe(1); + expect(await movementsFor(productId)).toHaveLength(0); + }); + + // ── Atomicity (I5) — real D1 + fault injection ───────────────────────────── + + it("T12: manual cancel fault mid-sequence — must leave NO partial state", async () => { + const { orderId, productId } = await seedOrder(); + + const faulty = makeFaultyDb((sql) => + sql.includes('update "products"') && sql.includes("inventory"), + ); + + await expect( + updateOrderStatus(faulty, orderId, "cancelled"), + ).rejects.toThrow(); + + // I5: if atomic, the status write rolled back too — retry is safe. + expect(await orderStatus(orderId)).toBe("new"); + expect(await inventoryOf(productId)).toBe(INITIAL_INVENTORY - 2); + }); + + it("T13: createOrder fault mid-sequence — must leave NO ghost order", async () => { + const customerId = await seedCustomer(); + const productId = await seedProduct(); + const orderId = `ord-lc-${++seq}`; + const now = new Date().toISOString(); + + const faulty = makeFaultyDb((sql) => sql.includes('insert into "order_products"')); + + await expect( + createOrder(faulty, { + id: orderId, orderNumber: `ORD-LC-${seq}`, customerId, + customerName: "LC Customer", phone: "0555000001", price: 2000, + status: "new", deliveryMethod: "unassigned", deliveryType: "home", + deliveryFee: 0, driverFee: 0, codAmount: 2000, createdAt: now, updatedAt: now, + }, [{ + id: `op-lc-${seq}`, orderId, productId, productName: "LC Product", + quantity: 2, pricePerUnit: 1000, lineTotal: 2000, createdAt: now, + }], { id: "u1", name: "A" }), + ).rejects.toThrow(); + + // I5: the orders row must not exist without its products/stats. + expect(await db.select().from(schema.orders).where(eq(schema.orders.id, orderId)).get()).toBeUndefined(); + expect(await customerOf(customerId)).toMatchObject({ totalOrders: 0, totalSpent: 0 }); + }); + + it("T14: webhook cancel fault — atomic batch leaves NO partial state (control)", async () => { + const { orderId, productId, customerId } = await seedOrder(); + + const faulty = makeFaultyDb((sql) => + (sql.includes('update "products"') || sql.includes('update "orders"')), + ); + + await expect( + updateOrderStatusWebhook(faulty, orderId, "cancelled", "e2e"), + ).rejects.toThrow(); + + // The webhook path batches everything — nothing committed. + expect(await orderStatus(orderId)).toBe("new"); + expect(await inventoryOf(productId)).toBe(INITIAL_INVENTORY - 2); + expect(await customerOf(customerId)).toMatchObject({ totalSpent: 2000 }); + }); +}); diff --git a/cod-server/src/endpoints/orders/orders.test.ts b/cod-server/src/endpoints/orders/orders.test.ts index 5528c4c..2f00a90 100644 --- a/cod-server/src/endpoints/orders/orders.test.ts +++ b/cod-server/src/endpoints/orders/orders.test.ts @@ -867,8 +867,9 @@ describe("Orders — targeted business-logic tests", () => { expect.objectContaining({ price: 12000 }) ); - // codAmount is NOT in the sync call — it stays stale at 9600 despite price being 12000. - // This is the documented gap: carrier collects 12600 but our DB says 9600. + // The caller passes only price — syncOrderAfterCarrierUpdate recomputes + // codAmount internally (price + deliveryFee) so settlement follows the + // carrier amount. Query-level behavior is locked in flagged-fixes.e2e. expect(queries.syncOrderAfterCarrierUpdate).not.toHaveBeenCalledWith( expect.anything(), "ord_1", diff --git a/cod-server/src/endpoints/orders/orders.webhook-e2e.test.ts b/cod-server/src/endpoints/orders/orders.webhook-e2e.test.ts index c3ba1e3..5fbd717 100644 --- a/cod-server/src/endpoints/orders/orders.webhook-e2e.test.ts +++ b/cod-server/src/endpoints/orders/orders.webhook-e2e.test.ts @@ -18,6 +18,7 @@ beforeAll(async () => { }); const d1 = await mf.getD1Database("DB"); const dir = resolve(__dirname, "../../db/migrations"); + const preparedStatements: D1PreparedStatement[] = []; for (const file of readdirSync(dir).filter((f) => f.endsWith(".sql")).sort()) { const statements = readFileSync(`${dir}/${file}`, "utf8") .split("--> statement-breakpoint") @@ -25,9 +26,12 @@ beforeAll(async () => { .map((s) => s.replace(/;+\s*$/, "").trim()) .filter((s) => s.replace(/--[^\n]*/g, "").trim().length > 0); for (const statement of statements) { - await d1.prepare(statement).run(); + preparedStatements.push(d1.prepare(statement)); } } + for (let i = 0; i < preparedStatements.length; i += 50) { + await d1.batch(preparedStatements.slice(i, i + 50)); + } db = drizzle(d1 as unknown as D1Database, { schema }) as unknown as AppDb; registry.push(mf); }, 120_000); diff --git a/cod-server/src/endpoints/products/products.list-e2e.test.ts b/cod-server/src/endpoints/products/products.list-e2e.test.ts index 3dda37b..1ad1150 100644 --- a/cod-server/src/endpoints/products/products.list-e2e.test.ts +++ b/cod-server/src/endpoints/products/products.list-e2e.test.ts @@ -40,6 +40,7 @@ beforeAll(async () => { registry.push(mf); const d1 = await mf.getD1Database("DB"); const dir = resolve(__dirname, "../../db/migrations"); + const preparedStatements: D1PreparedStatement[] = []; for (const file of readdirSync(dir).filter((f) => f.endsWith(".sql")).sort()) { const statements = readFileSync(`${dir}/${file}`, "utf8") .split("--> statement-breakpoint") @@ -47,9 +48,12 @@ beforeAll(async () => { .map((s) => s.replace(/;+\s*$/, "").trim()) .filter((s) => s.replace(/--[^\n]*/g, "").trim().length > 0); for (const statement of statements) { - await d1.prepare(statement).run(); + preparedStatements.push(d1.prepare(statement)); } } + for (let i = 0; i < preparedStatements.length; i += 50) { + await d1.batch(preparedStatements.slice(i, i + 50)); + } db = drizzle(d1 as unknown as D1Database, { schema }) as unknown as AppDb; const now = new Date().toISOString(); diff --git a/cod-server/src/endpoints/stock/CONTEXT.md b/cod-server/src/endpoints/stock/CONTEXT.md index 9fc0663..a2c2d27 100644 --- a/cod-server/src/endpoints/stock/CONTEXT.md +++ b/cod-server/src/endpoints/stock/CONTEXT.md @@ -55,6 +55,7 @@ _Avoid_: Random order, alphabetical triage Terms owned by neighboring contexts — use them, don't redefine them here: - **Automatic deduction and restoration**: Orders context writes ORDER_* movements during creation, cancellation, return, and deletion — this module only performs deliberate manual adjustments +- **Catalog inventory edits**: Products/Variants contexts now write ADJUSTMENT_* movements when a merchant edits stock directly — opening stock on variant create, deltas on inventory edits. Variant deletion removes the variant's scoped movements with the row (FK cascade), keeping the ledger sum reconciled - **Where inventory lives**: Products context owns the fields; this context reads and updates them - **Reward and offer stock checks**: Store context consults inventory before inserting free lines - **Selling price used for valuation**: Products context pricing — never cost price diff --git a/cod-server/src/endpoints/stock/list-count-e2e.test.ts b/cod-server/src/endpoints/stock/list-count-e2e.test.ts index 61fbbb2..dc3cfa9 100644 --- a/cod-server/src/endpoints/stock/list-count-e2e.test.ts +++ b/cod-server/src/endpoints/stock/list-count-e2e.test.ts @@ -37,6 +37,7 @@ beforeAll(async () => { registry.push(mf); const d1 = await mf.getD1Database("DB"); const dir = resolve(__dirname, "../../db/migrations"); + const preparedStatements: D1PreparedStatement[] = []; for (const file of readdirSync(dir).filter((f) => f.endsWith(".sql")).sort()) { const statements = readFileSync(`${dir}/${file}`, "utf8") .split("--> statement-breakpoint") @@ -44,9 +45,12 @@ beforeAll(async () => { .map((s) => s.replace(/;+\s*$/, "").trim()) .filter((s) => s.replace(/--[^\n]*/g, "").trim().length > 0); for (const statement of statements) { - await d1.prepare(statement).run(); + preparedStatements.push(d1.prepare(statement)); } } + for (let i = 0; i < preparedStatements.length; i += 50) { + await d1.batch(preparedStatements.slice(i, i + 50)); + } db = drizzle(d1 as unknown as D1Database, { schema }) as unknown as AppDb; const now = new Date().toISOString(); diff --git a/cod-server/src/endpoints/stock/queries.ts b/cod-server/src/endpoints/stock/queries.ts index 1f88805..d510b6a 100644 --- a/cod-server/src/endpoints/stock/queries.ts +++ b/cod-server/src/endpoints/stock/queries.ts @@ -5,7 +5,7 @@ * adjustStock stays here because it raises NotFoundError / BusinessLogicError. */ -import { eq } from "drizzle-orm"; +import { eq, and, sql } from "drizzle-orm"; import { products, productVariants, stockMovements } from "@/db/schema"; import type { AppDb } from "@/db"; import type { AdjustStockInput } from "./validation"; @@ -31,6 +31,16 @@ export type { import type { StockMovementRow } from "../../../../cod-shared/queries/stock"; +type BatchStatement = Parameters[0][number]; + +/** Movement types whose semantics fix the delta's sign. */ +const TYPE_SIGN: Record = { + PURCHASE: 1, + ADJUSTMENT_ADD: 1, + ADJUSTMENT_REMOVE: -1, + OFFLINE_SALE: -1, +}; + export async function adjustStock( db: AppDb, params: { @@ -43,6 +53,17 @@ export async function adjustStock( ): Promise<{ movement: StockMovementRow; currentInventory: number }> { const { productId, variantId, type, delta, reason, createdBy, createdByName, reference } = params; + // Ledger honesty: the movement type must agree with the delta direction — + // an ADJUSTMENT_ADD with a negative delta labels the ledger row a lie. + const expectedSign = TYPE_SIGN[type]; + if (expectedSign !== undefined && Math.sign(delta) !== expectedSign) { + throw new BusinessLogicError( + `Movement type ${type} requires a ${expectedSign > 0 ? "positive" : "negative"} delta (got ${delta})`, + ERROR_CODES.VALIDATION_FAILED, + { type, delta }, + ); + } + const { inventory: qtyBefore, exists } = await getProductInventory(db, productId, variantId ?? null); if (!exists) { @@ -53,6 +74,9 @@ export async function adjustStock( } } + // Friendly pre-check — keeps the 422 contract with available/required detail. + // The authoritative guard lives inside the atomic batch below, so a race + // that slips past this check still cannot corrupt stock. const qtyAfter = qtyBefore + delta; if (qtyAfter < 0) { const productRow = await db @@ -74,34 +98,59 @@ export async function adjustStock( } const now = new Date().toISOString(); + const movementId = crypto.randomUUID(); - if (variantId) { - await db - .update(productVariants) - .set({ inventory: qtyAfter, updatedAt: now }) - .where(eq(productVariants.id, variantId)); - } else { - await db - .update(products) - .set({ inventory: qtyAfter, updatedAt: now }) - .where(eq(products.id, productId)); - } + // Guarded atomic batch — movement first (its subselects read pre-update + // state), then the inventory UPDATE with `inventory + delta >= 0` in the + // WHERE. Two concurrent adjustments can no longer clobber each other, and + // the ledger row can never diverge from the applied change. + const guard = + variantId !== null + ? sql`FROM ${productVariants} WHERE ${productVariants.id} = ${variantId} AND ${productVariants.inventory} + ${delta} >= 0` + : sql`FROM ${products} WHERE ${products.id} = ${productId} AND ${products.inventory} + ${delta} >= 0`; + const inventoryColumn = + variantId !== null ? productVariants.inventory : products.inventory; - const movementId = crypto.randomUUID(); - await db.insert(stockMovements).values({ - id: movementId, - productId, - variantId: variantId ?? null, - type, - delta, - qtyBefore, - qtyAfter, - reason: reason ?? null, - reference: reference ?? null, - createdBy, - createdByName, - createdAt: now, - }); + const guardedUpdate = + variantId !== null + ? db + .update(productVariants) + .set({ inventory: sql`${productVariants.inventory} + ${delta}`, updatedAt: now }) + .where( + and( + eq(productVariants.id, variantId), + sql`${productVariants.inventory} + ${delta} >= 0`, + ), + ) + : db + .update(products) + .set({ inventory: sql`${products.inventory} + ${delta}`, updatedAt: now }) + .where( + and( + eq(products.id, productId), + sql`${products.inventory} + ${delta} >= 0`, + ), + ); + + await db.batch([ + db.insert(stockMovements).values({ + id: movementId, + productId, + variantId: variantId ?? null, + type, + delta, + qtyBefore: sql`(SELECT ${inventoryColumn} ${guard})`, + qtyAfter: sql`(SELECT ${inventoryColumn} + ${delta} ${guard})`, + reason: reason ?? null, + reference: reference ?? null, + createdBy, + createdByName, + createdAt: now, + }), + guardedUpdate, + ] as [BatchStatement, ...BatchStatement[]]); + + const { inventory: currentInventory } = await getProductInventory(db, productId, variantId ?? null); const movement: StockMovementRow = { id: movementId, @@ -109,8 +158,8 @@ export async function adjustStock( variantId: variantId ?? null, type, delta, - qtyBefore, - qtyAfter, + qtyBefore: qtyBefore, + qtyAfter: currentInventory, reason: reason ?? null, reference: reference ?? null, createdBy, @@ -118,5 +167,5 @@ export async function adjustStock( createdAt: now, }; - return { movement, currentInventory: qtyAfter }; + return { movement, currentInventory }; } diff --git a/cod-server/src/endpoints/stock/stock.overview-e2e.test.ts b/cod-server/src/endpoints/stock/stock.overview-e2e.test.ts index ac17143..8e83692 100644 --- a/cod-server/src/endpoints/stock/stock.overview-e2e.test.ts +++ b/cod-server/src/endpoints/stock/stock.overview-e2e.test.ts @@ -36,6 +36,7 @@ beforeAll(async () => { registry.push(mf); const d1 = await mf.getD1Database("DB"); const dir = resolve(__dirname, "../../db/migrations"); + const preparedStatements: D1PreparedStatement[] = []; for (const file of readdirSync(dir).filter((f) => f.endsWith(".sql")).sort()) { const statements = readFileSync(`${dir}/${file}`, "utf8") .split("--> statement-breakpoint") @@ -43,9 +44,12 @@ beforeAll(async () => { .map((s) => s.replace(/;+\s*$/, "").trim()) .filter((s) => s.replace(/--[^\n]*/g, "").trim().length > 0); for (const statement of statements) { - await d1.prepare(statement).run(); + preparedStatements.push(d1.prepare(statement)); } } + for (let i = 0; i < preparedStatements.length; i += 50) { + await d1.batch(preparedStatements.slice(i, i + 50)); + } db = drizzle(d1 as unknown as D1Database, { schema }) as unknown as AppDb; const now = new Date().toISOString(); diff --git a/cod-server/src/endpoints/stock/stock.test.ts b/cod-server/src/endpoints/stock/stock.test.ts index 9036ff8..7aae88b 100644 --- a/cod-server/src/endpoints/stock/stock.test.ts +++ b/cod-server/src/endpoints/stock/stock.test.ts @@ -89,6 +89,7 @@ describe("adjustStock - Error Scenarios", () => { const params = { ...validParams, + type: "ADJUSTMENT_REMOVE" as const, delta: -10, // Trying to deduct 10 when only 5 available }; @@ -131,6 +132,7 @@ describe("adjustStock - Error Scenarios", () => { const params = { ...validParams, + type: "ADJUSTMENT_REMOVE" as const, variantId, delta: -5, // Trying to deduct 5 when only 3 available }; @@ -160,6 +162,7 @@ describe("adjustStock - Error Scenarios", () => { const params = { ...validParams, + type: "ADJUSTMENT_REMOVE" as const, delta: -5, // This should work (5 - 5 = 0) }; @@ -177,6 +180,7 @@ describe("adjustStock - Error Scenarios", () => { const params = { ...validParams, + type: "ADJUSTMENT_REMOVE" as const, delta: -6, // This should fail (5 - 6 = -1) }; @@ -229,6 +233,7 @@ describe("adjustStock - Error Scenarios", () => { const params = { ...validParams, + type: "ADJUSTMENT_REMOVE" as const, delta: -5, }; diff --git a/cod-server/src/endpoints/store/CONTEXT.md b/cod-server/src/endpoints/store/CONTEXT.md index b929c60..86a59b5 100644 --- a/cod-server/src/endpoints/store/CONTEXT.md +++ b/cod-server/src/endpoints/store/CONTEXT.md @@ -30,9 +30,9 @@ _Avoid_: Cart order, multi-item order Checkout looks up the customer by phone; an existing customer is reused exactly as-is, otherwise a new one is created from the checkout fields. _Avoid_: Registration, sign-up -**Client-Supplied Price**: -The unit price arrives from the storefront with the order, and the server computes totals from it rather than re-reading the catalog price. -_Avoid_: Server-priced total, catalog lookup price +**Server-Authoritative Pricing**: +The unit price is resolved from the catalog at order time — the product's own price for simple products, each variant's price for variant orders. The client-sent unit price is accepted in the payload for display continuity but never trusted for money math. Totals, COD amounts, customer spend, and driver cash all derive from catalog prices. +_Avoid_: Client-supplied price, trusted unit price **Variant Selections**: One entry per ordered unit when different variants are mixed; identical units collapse into a single order line and stock deducts per variant. @@ -83,12 +83,12 @@ Terms owned by neighboring contexts — use them, don't redefine them here: ## Edge Cases -**Prices arrive from the browser**: Totals run on the client-sent unit price. That is today's contract — treat any "hardening" as a code change, not a docs fix. +**Prices are resolved server-side**: A forged client unit price changes nothing — the catalog row is the only price source for storefront orders. Dashboard-created orders (authenticated staff) may still set custom prices; that flexibility is a merchant feature, not a hole. **Repeat buyers keep their record**: A returning phone reuses the stored customer untouched — the new checkout's name or wilaya never overwrites it. **Rewards vanish quietly**: An out-of-stock Buy X Get Y reward disappears from the order without error or notice to the caller. -**Absent wilayas mean unsupported**: The shipping-rates map simply omits uncovered wilayas; clients must treat missing keys as no-delivery. +**Delivery availability is server-enforced**: A wilaya the default profile does not cover — or a delivery type the merchant disabled — cannot be ordered: the API refuses with DELIVERY_NOT_AVAILABLE instead of charging 0. Only a store with no shipping profile at all accepts orders at fee 0. **Limits are capped twice**: Clients may ask for fewer results, but the server clamps page sizes regardless of what was requested. diff --git a/cod-server/src/endpoints/store/handlers.ts b/cod-server/src/endpoints/store/handlers.ts index bc44556..b0e1122 100644 --- a/cod-server/src/endpoints/store/handlers.ts +++ b/cod-server/src/endpoints/store/handlers.ts @@ -96,6 +96,25 @@ export async function createStoreOrder(c: Context) { await assertOtpVerification(c, db, data); + const deliveryFee = await queries.getDeliveryFee( + db, + data.wilayaId, + data.deliveryType + ); + + if (deliveryFee === null) { + // Delivery to this wilaya (or this delivery type) is not configured — + // refuse the order instead of silently shipping for free. Refusal must + // happen BEFORE the customer is created so no orphan customer rows. + throw new BusinessLogicError( + data.deliveryType === "home" + ? "Home delivery is not available to this wilaya" + : "Stop-desk delivery is not available to this wilaya", + ERROR_CODES.DELIVERY_NOT_AVAILABLE, + { wilayaId: data.wilayaId, deliveryType: data.deliveryType } + ); + } + const customer = await queries.findOrCreateCustomer(db, { phone: data.phone, name: data.customerName, @@ -103,12 +122,6 @@ export async function createStoreOrder(c: Context) { communeId: data.communeId, }); - const deliveryFee = await queries.getDeliveryFee( - db, - data.wilayaId, - data.deliveryType - ); - // X-Forwarded-For first: the storefront worker forwards the shopper's IP // there — CF-Connecting-IP on this hop is the worker itself. const ipAddress = diff --git a/cod-server/src/endpoints/store/store.queries-e2e.test.ts b/cod-server/src/endpoints/store/store.queries-e2e.test.ts index 06dbd42..f9c5004 100644 --- a/cod-server/src/endpoints/store/store.queries-e2e.test.ts +++ b/cod-server/src/endpoints/store/store.queries-e2e.test.ts @@ -37,6 +37,7 @@ beforeAll(async () => { registry.push(mf); const d1 = await mf.getD1Database("DB"); const dir = resolve(__dirname, "../../db/migrations"); + const preparedStatements: D1PreparedStatement[] = []; for (const file of readdirSync(dir).filter((f) => f.endsWith(".sql")).sort()) { const statements = readFileSync(`${dir}/${file}`, "utf8") .split("--> statement-breakpoint") @@ -44,9 +45,12 @@ beforeAll(async () => { .map((s) => s.replace(/;+\s*$/, "").trim()) .filter((s) => s.replace(/--[^\n]*/g, "").trim().length > 0); for (const statement of statements) { - await d1.prepare(statement).run(); + preparedStatements.push(d1.prepare(statement)); } } + for (let i = 0; i < preparedStatements.length; i += 50) { + await d1.batch(preparedStatements.slice(i, i + 50)); + } db = drizzle(d1 as unknown as D1Database, { schema }) as unknown as AppDb; }, 120_000); diff --git a/cod-server/src/endpoints/store/store.queries.test.ts b/cod-server/src/endpoints/store/store.queries.test.ts index fa845c7..aa7c3bd 100644 --- a/cod-server/src/endpoints/store/store.queries.test.ts +++ b/cod-server/src/endpoints/store/store.queries.test.ts @@ -84,8 +84,9 @@ describe("findOrCreateCustomer", () => { }); describe("createStoreOrder", () => { - it("commits a simple tracked order in one batch", async () => { + it("commits a simple tracked order in one batch (catalog price is authoritative)", async () => { const db = makeMockDb([ + f({ price: 1500, track_inventory: 1 }), // catalog price row (server-authoritative) a([]), // selectApplicableOffer — no candidates f({ sku: "TS-001" }), // product SKU select f({ track_inventory: 1 }), // trackInventory select @@ -100,16 +101,18 @@ describe("createStoreOrder", () => { deliveryFee: 400, }); + // quantity 2 × catalog price 1500 — the client's pricePerUnit is ignored expect(result).toMatchObject({ price: 3000, deliveryFee: 400 }); expect(result.orderNumber).toMatch(/^ORD-\d{8}-\d{4}$/); expect(result.id).toBeTruthy(); }); - it("commits a variant-selection order with grouped deductions", async () => { + it("commits a variant-selection order with grouped deductions (price = Σ lines)", async () => { const db = makeMockDb([ + f({ price: 1500, track_inventory: 1 }), // catalog price row a([]), // offers - f({ sku: "TS-RED" }), // variant SKU (group 1) - f({ sku: "TS-BLUE" }), // variant SKU (group 2) + f({ sku: "TS-RED", price: 1500 }), // variant row 1 (sku + price) + f({ sku: "TS-BLUE", price: 1500 }), // variant row 2 f({ track_inventory: 1 }), // trackInventory f({ inventory: 4 }), // inventory variant 1 f({ inventory: 2 }), // inventory variant 2 @@ -127,11 +130,13 @@ describe("createStoreOrder", () => { deliveryFee: 400, }); - expect(result).toMatchObject({ price: 3000 }); + // 2 × 1500 + 1 × 1500 — the true sum of the lines, not quantity × unit + expect(result).toMatchObject({ price: 4500 }); }); it("skips deduction entirely for untracked products", async () => { const db = makeMockDb([ + f({ price: 1500, track_inventory: 0 }), // catalog price row a([]), // offers f({ sku: "TS-001" }), // SKU f({ track_inventory: 0 }), // trackInventory — false @@ -154,6 +159,7 @@ describe("createStoreOrder", () => { reward_product_id: null, }); const db = makeMockDb([ + f({ price: 1500, track_inventory: 1 }), // catalog price row f(offer), // explicit offerId select f({ sku: "TS-001" }), // SKU f({ track_inventory: 1 }), // trackInventory @@ -178,6 +184,7 @@ describe("createStoreOrder", () => { reward_variant_id: "var_9", }); const db = makeMockDb([ + f({ price: 1500, track_inventory: 1 }), // catalog price row f(offer), // explicit offer select f({ sku: "TS-001" }), // order line SKU f({ variations: '{"Color":"Green"}' }), // reward variant variations @@ -208,6 +215,7 @@ describe("createStoreOrder", () => { reward_variant_id: "var_9", }); const db = makeMockDb([ + f({ price: 1500, track_inventory: 1 }), // catalog price row f(offer), // offer f({ sku: "TS-001" }), // SKU f({ variations: '{"Color":"Green"}' }), // reward variations diff --git a/cod-server/src/endpoints/store/validation.ts b/cod-server/src/endpoints/store/validation.ts index 2a42b4a..3eb0953 100644 --- a/cod-server/src/endpoints/store/validation.ts +++ b/cod-server/src/endpoints/store/validation.ts @@ -28,6 +28,8 @@ export const storeOrderSchema = z.object({ variantId: z.string().min(1).optional(), variantLabel: z.string().max(100).optional(), quantity: z.number().int().min(1).max(100).default(1), + // Display-only: accepted for storefront UI continuity but NEVER trusted for + // pricing — the server resolves the unit price from the catalog row. pricePerUnit: z.number().positive(), notes: z.string().max(500).optional(), // Explicit offer selection from client — server applies this exact offer rather than auto-detecting diff --git a/cod-server/src/endpoints/variants/variants.test.ts b/cod-server/src/endpoints/variants/variants.test.ts index ba37834..1abda64 100644 --- a/cod-server/src/endpoints/variants/variants.test.ts +++ b/cod-server/src/endpoints/variants/variants.test.ts @@ -144,8 +144,12 @@ describe("getVariantById", () => { describe("createVariant", () => { it("creates and returns the new variant", async () => { - // INSERT (run) → getVariantById → productVariants.get() - const db = makeMockDb([f(variantRow({ product_id: "prod_1" }))]); + // batch INSERT (run — no queue consumption) → trackInventory check (f) + // → getVariantById → productVariants.get() + const db = makeMockDb([ + f({ track_inventory: 1 }), + f(variantRow({ product_id: "prod_1" })), + ]); const result = await createVariant(db as any, "prod_1", { variations: { Color: "Red" }, price: 1500, @@ -178,8 +182,13 @@ describe("createVariant", () => { describe("updateVariant", () => { it("updates and returns the variant", async () => { - // UPDATE (run with no queue) → getVariantById → productVariants.get() - const db = makeMockDb([f(variantRow({ inventory: 20 }))]); + // batch UPDATE (run — no queue) → inventory pre-read (f) → trackInventory (f) + // → getVariantById → productVariants.get() + const db = makeMockDb([ + f({ product_id: "prod_1", inventory: 20 }), + f({ track_inventory: 1 }), + f(variantRow({ inventory: 20 })), + ]); const result = await updateVariant(db as any, "var_1", { inventory: 20 }); expect(result).not.toBeNull(); expect(result!.inventory).toBe(20); diff --git a/cod-server/src/middleware/error.test.ts b/cod-server/src/middleware/error.test.ts index 27e62e0..e26d916 100644 --- a/cod-server/src/middleware/error.test.ts +++ b/cod-server/src/middleware/error.test.ts @@ -234,6 +234,16 @@ describe("Error Middleware", () => { ); }); + it("should never leak raw message content for unknown errors", () => { + const error = new Error("Failed query: delete from \"orders\" where \"orders\".\"id\" = ?"); + + const context = createMockContext(); + errorHandler(error, context); + + const callArgs = (context.json as any).mock.calls[0][0]; + expect(callArgs.error).toBe("An unexpected error occurred"); + }); + it("should sanitize error messages with sensitive information", () => { const error = new Error("API key abc123 is invalid"); @@ -303,14 +313,14 @@ describe("Error Middleware", () => { expect(callArgs.context).toEqual({ customerId: "123" }); }); - it("should not include context field when not available", () => { + it("should include a requestId in context for unknown errors", () => { const error = new Error("Generic error"); const context = createMockContext(); errorHandler(error, context); const callArgs = (context.json as any).mock.calls[0][0]; - expect(callArgs.context).toBeUndefined(); + expect(callArgs.context).toEqual({ requestId: expect.any(String) }); }); }); diff --git a/cod-server/src/middleware/error.ts b/cod-server/src/middleware/error.ts index 5b1ebf3..5e542ef 100644 --- a/cod-server/src/middleware/error.ts +++ b/cod-server/src/middleware/error.ts @@ -1,6 +1,6 @@ /** * Error Handling Middleware - * + * * Global error handler for consistent error responses. * Handles custom AppError instances, ZodError validation, and unknown errors. */ @@ -20,49 +20,13 @@ export interface ErrorResponse { context?: Record; } -/** - * Sanitize error message to remove sensitive information - */ -function sanitizeErrorMessage(message: string): string { - // Remove file paths - message = message.replace(/\/[^\s]+\.(ts|js|tsx|jsx)/g, "[file]"); - - // Remove stack trace lines - message = message.replace(/at\s+[^\n]+/g, ""); - - // Remove internal variable names (e.g., "variable_name is not defined") - message = message.replace(/\b[a-z_][a-z0-9_]*\b/gi, (match) => { - // Keep common words - const commonWords = ["is", "not", "defined", "null", "undefined", "error", "failed"]; - return commonWords.includes(match.toLowerCase()) ? match : "[variable]"; - }); - - return message.trim(); -} - -/** - * Check if message contains sensitive information - */ -function containsSensitiveInfo(message: string): boolean { - const sensitivePatterns = [ - /api[_\-\s]?key/i, - /password/i, - /secret/i, - /token/i, - /\/[a-z0-9_-]+\/[a-z0-9_-]+\//i, // file paths - /at\s+[^\n]+/i, // stack traces - ]; - - return sensitivePatterns.some((pattern) => pattern.test(message)); -} - /** * Global error handler middleware */ export function errorHandler(err: Error, c: Context): Response { // Generate request ID for tracing const requestId = crypto.randomUUID(); - + // Log detailed error server-side console.error("[Error Handler]", { requestId, @@ -90,7 +54,7 @@ export function errorHandler(err: Error, c: Context): Response { })), }, }; - + return c.json(response, 400); } @@ -102,20 +66,19 @@ export function errorHandler(err: Error, c: Context): Response { category: err.category, context: err.context, }; - + return c.json(response, err.statusCode as any); } - // Handle unknown errors - sanitize for security - const safeMessage = containsSensitiveInfo(err.message) - ? "An unexpected error occurred" - : sanitizeErrorMessage(err.message); - + // Handle unknown errors — never leak the raw message (it can carry SQL, + // table/column names, file paths, or secrets). The full details are in the + // server log under this requestId; return the ID so the client can report it. const response: ErrorResponse = { - error: safeMessage, + error: "An unexpected error occurred", code: ERROR_CODES.INTERNAL_SERVER_ERROR, category: ERROR_CATEGORIES.SYSTEM, + context: { requestId }, }; - + return c.json(response, 500); } diff --git a/cod-shared/queries/drivers.ts b/cod-shared/queries/drivers.ts index ea14bc0..da721e4 100644 --- a/cod-shared/queries/drivers.ts +++ b/cod-shared/queries/drivers.ts @@ -104,6 +104,47 @@ export async function getAllDrivers(db: AppDb, filters?: DriverFilters) { })); } +/** + * Driver cash reconciliation: compares the driver's denormalized pendingCash + * counter against the authoritative sum of delivered-but-unsettled orders. + * drift ≠ 0 means the ledger and reality disagree — damage from an old bug, + * a manual D1 edit, or a mid-settlement failure. Surfaced so ops can see it. + */ +export async function getDriverCashReconciliation(db: AppDb, driverId: string) { + const [driverRow, pendingRow] = await Promise.all([ + db + .select({ pendingCash: drivers.pendingCash }) + .from(drivers) + .where(eq(drivers.id, driverId)) + .get(), + db + .select({ + total: sql`coalesce(sum(${orders.codAmount}), 0)`, + c: count(), + }) + .from(orders) + .where( + and( + eq(orders.driverId, driverId), + eq(orders.status, "delivered"), + sql`${orders.codPaymentId} IS NULL`, + ), + ) + .get(), + ]); + + const pendingCash = Number(driverRow?.pendingCash ?? 0); + const pendingOrdersTotal = Number(pendingRow?.total ?? 0); + const pendingOrdersCount = Number(pendingRow?.c ?? 0); + + return { + pendingCash, + pendingOrdersTotal, + pendingOrdersCount, + drift: pendingCash - pendingOrdersTotal, + }; +} + export async function getDriverById(db: AppDb, driverId: string) { const driver = await db .select() @@ -122,18 +163,22 @@ export async function getDriverById(db: AppDb, driverId: string) { .where(eq(driverCompensations.driverId, driverId)) .get(); - const recentOrders = await db - .select() - .from(orders) - .where(eq(orders.driverId, driverId)) - .orderBy(desc(orders.updatedAt)) - .limit(10) - .all(); + const [recentOrders, cashReconciliation] = await Promise.all([ + db + .select() + .from(orders) + .where(eq(orders.driverId, driverId)) + .orderBy(desc(orders.updatedAt)) + .limit(10) + .all(), + getDriverCashReconciliation(db, driverId), + ]); return { ...driver, compensationWilayaCount: compStats?.c ?? 0, recentOrders, + cashReconciliation, }; } diff --git a/cod-shared/queries/orders.ts b/cod-shared/queries/orders.ts index 5425dee..f533c1c 100644 --- a/cod-shared/queries/orders.ts +++ b/cod-shared/queries/orders.ts @@ -19,6 +19,8 @@ import { communes, stockMovements, companyShipments, + companyApiLogs, + webhookEvents, } from "../db/schema"; import type { OrderStatus } from "../db/schema"; import { @@ -193,30 +195,37 @@ export async function createOrder( productsData: Array, actor?: { id: string; name: string } | null, ) { - await db.insert(orders).values(orderData); + const now = orderData.createdAt ?? new Date().toISOString(); + + const statements: BatchStatement[] = [ + db.insert(orders).values(orderData), + ]; if (productsData.length > 0) { - await db.insert(orderProducts).values(productsData); + statements.push(db.insert(orderProducts).values(productsData)); } - await db.insert(orderStatusHistory).values({ - id: crypto.randomUUID(), - orderId: orderData.id!, - status: orderData.status!, - timestamp: orderData.createdAt!, - by: null, - }); + statements.push( + db.insert(orderStatusHistory).values({ + id: crypto.randomUUID(), + orderId: orderData.id!, + status: orderData.status!, + timestamp: orderData.createdAt!, + by: null, + }), + ); - await db - .update(customers) - .set({ - totalOrders: sql`${customers.totalOrders} + 1`, - totalSpent: sql`${customers.totalSpent} + ${orderData.price ?? 0}`, - lastOrderAt: orderData.createdAt, - }) - .where(eq(customers.id, orderData.customerId)); + statements.push( + db + .update(customers) + .set({ + totalOrders: sql`${customers.totalOrders} + 1`, + totalSpent: sql`${customers.totalSpent} + ${orderData.price ?? 0}`, + lastOrderAt: orderData.createdAt, + }) + .where(eq(customers.id, orderData.customerId)), + ); - const now = orderData.createdAt ?? new Date().toISOString(); for (const item of productsData) { const qty = item.quantity as number; @@ -228,77 +237,82 @@ export async function createOrder( if (!productRow?.trackInventory) continue; + // Guarded deduction, same pattern as the storefront path: the movement's + // qtyBefore/qtyAfter are subselects guarded by inventory >= qty. When + // stock is insufficient (or a concurrent writer already took it), the + // subselects return NULL, the movement insert violates NOT NULL, and the + // ENTIRE batch rolls back — no oversell floor, no lost-update race. if (item.variantId) { - const variantRow = await db - .select({ inventory: productVariants.inventory }) - .from(productVariants) - .where(eq(productVariants.id, item.variantId)) - .get(); - - const qtyBefore = variantRow?.inventory ?? 0; - const qtyAfter = Math.max(0, qtyBefore - qty); - - await db - .update(productVariants) - .set({ inventory: qtyAfter, updatedAt: now }) - .where(eq(productVariants.id, item.variantId)); - - await db - .insert(stockMovements) - .values({ + statements.push( + db.insert(stockMovements).values({ id: crypto.randomUUID(), productId: item.productId, variantId: item.variantId, type: "ORDER_DEDUCTED", - delta: -(qtyBefore - qtyAfter), - qtyBefore, - qtyAfter, + delta: -qty, + qtyBefore: sql`(SELECT inventory FROM product_variants WHERE id = ${item.variantId} AND inventory >= ${qty})`, + qtyAfter: sql`(SELECT inventory - ${qty} FROM product_variants WHERE id = ${item.variantId} AND inventory >= ${qty})`, reason: null, reference: orderData.id ?? null, createdBy: actor?.id ?? "system", createdByName: actor?.name ?? "النظام", createdAt: now, - }) - .catch((err) => - console.error("[stock] Failed to log ORDER_DEDUCTED movement:", err), - ); + }), + ); + statements.push( + db + .update(productVariants) + .set({ + inventory: sql`${productVariants.inventory} - ${qty}`, + updatedAt: now, + }) + .where( + and( + eq(productVariants.id, item.variantId), + sql`${productVariants.inventory} >= ${qty}`, + ), + ), + ); } else { - const productInventoryRow = await db - .select({ inventory: products.inventory }) - .from(products) - .where(eq(products.id, item.productId)) - .get(); - - const qtyBefore = productInventoryRow?.inventory ?? 0; - const qtyAfter = Math.max(0, qtyBefore - qty); - - await db - .update(products) - .set({ inventory: qtyAfter, updatedAt: now }) - .where(eq(products.id, item.productId)); - - await db - .insert(stockMovements) - .values({ + statements.push( + db.insert(stockMovements).values({ id: crypto.randomUUID(), productId: item.productId, variantId: null, type: "ORDER_DEDUCTED", - delta: -(qtyBefore - qtyAfter), - qtyBefore, - qtyAfter, + delta: -qty, + qtyBefore: sql`(SELECT inventory FROM products WHERE id = ${item.productId} AND inventory >= ${qty})`, + qtyAfter: sql`(SELECT inventory - ${qty} FROM products WHERE id = ${item.productId} AND inventory >= ${qty})`, reason: null, reference: orderData.id ?? null, createdBy: actor?.id ?? "system", createdByName: actor?.name ?? "النظام", createdAt: now, - }) - .catch((err) => - console.error("[stock] Failed to log ORDER_DEDUCTED movement:", err), - ); + }), + ); + statements.push( + db + .update(products) + .set({ + inventory: sql`${products.inventory} - ${qty}`, + updatedAt: now, + }) + .where( + and( + eq(products.id, item.productId), + sql`${products.inventory} >= ${qty}`, + ), + ), + ); } } + // Atomic: order row, lines, history, customer stats, stock deduction, and + // ledger commit together or not at all. Guarded deductions make the batch + // fail (and roll back entirely) when stock is insufficient — no silent + // floor-at-zero, no lost-update races. + await db.batch(statements as [BatchStatement, ...BatchStatement[]]); + return orderData.id; } @@ -313,33 +327,37 @@ export async function updateOrderStatus( const order = await db.select().from(orders).where(eq(orders.id, orderId)).get(); - await db - .update(orders) - .set({ - status: newStatus, - updatedAt: now, - ...(newStatus === "delivered" ? { deliveryTime: now } : {}), - }) - .where(eq(orders.id, orderId)); - - await db.insert(orderStatusHistory).values({ - id: crypto.randomUUID(), - orderId, - status: newStatus, - timestamp: now, - by: userId ?? null, - }); - - if (newStatus === "delivered" && order?.driverId) { - await db - .update(drivers) + const statements: BatchStatement[] = [ + db + .update(orders) .set({ - totalDelivered: sql`${drivers.totalDelivered} + 1`, - totalEarnings: sql`${drivers.totalEarnings} + ${order.driverFee ?? 0}`, - pendingCash: sql`${drivers.pendingCash} + ${order.codAmount ?? 0}`, + status: newStatus, updatedAt: now, + ...(newStatus === "delivered" ? { deliveryTime: now } : {}), }) - .where(eq(drivers.id, order.driverId)); + .where(eq(orders.id, orderId)), + + db.insert(orderStatusHistory).values({ + id: crypto.randomUUID(), + orderId, + status: newStatus, + timestamp: now, + by: userId ?? null, + }), + ]; + + if (newStatus === "delivered" && order?.driverId) { + statements.push( + db + .update(drivers) + .set({ + totalDelivered: sql`${drivers.totalDelivered} + 1`, + totalEarnings: sql`${drivers.totalEarnings} + ${order.driverFee ?? 0}`, + pendingCash: sql`${drivers.pendingCash} + ${order.codAmount ?? 0}`, + updatedAt: now, + }) + .where(eq(drivers.id, order.driverId)), + ); } const terminalStatuses = ["cancelled", "returned"]; @@ -347,12 +365,14 @@ export async function updateOrderStatus( if (!wasAlreadyTerminal && (newStatus === "cancelled" || newStatus === "returned")) { // Update customer totalSpent when order is cancelled/returned - await db - .update(customers) - .set({ - totalSpent: sql`MAX(0, ${customers.totalSpent} - ${order?.price ?? 0})`, - }) - .where(eq(customers.id, order?.customerId ?? "")); + statements.push( + db + .update(customers) + .set({ + totalSpent: sql`MAX(0, ${customers.totalSpent} - ${order?.price ?? 0})`, + }) + .where(eq(customers.id, order?.customerId ?? "")), + ); const movementType = newStatus === "cancelled" ? "ORDER_CANCELLED" : "ORDER_RETURNED"; @@ -391,14 +411,15 @@ export async function updateOrderStatus( const qtyBefore = variantRow?.inventory ?? 0; const qtyAfter = qtyBefore + remaining; - await db - .update(productVariants) - .set({ inventory: qtyAfter, updatedAt: now }) - .where(eq(productVariants.id, op.variantId)); + statements.push( + db + .update(productVariants) + .set({ inventory: qtyAfter, updatedAt: now }) + .where(eq(productVariants.id, op.variantId)), + ); - await db - .insert(stockMovements) - .values({ + statements.push( + db.insert(stockMovements).values({ id: crypto.randomUUID(), productId: op.productId, variantId: op.variantId, @@ -411,10 +432,8 @@ export async function updateOrderStatus( createdBy: userId ?? "system", createdByName: userName ?? "النظام", createdAt: now, - }) - .catch((err) => - console.error("[stock] Failed to log", movementType, "movement:", err), - ); + }), + ); } else { const productInventoryRow = await db .select({ inventory: products.inventory }) @@ -425,14 +444,15 @@ export async function updateOrderStatus( const qtyBefore = productInventoryRow?.inventory ?? 0; const qtyAfter = qtyBefore + remaining; - await db - .update(products) - .set({ inventory: qtyAfter, updatedAt: now }) - .where(eq(products.id, op.productId)); + statements.push( + db + .update(products) + .set({ inventory: qtyAfter, updatedAt: now }) + .where(eq(products.id, op.productId)), + ); - await db - .insert(stockMovements) - .values({ + statements.push( + db.insert(stockMovements).values({ id: crypto.randomUUID(), productId: op.productId, variantId: null, @@ -445,19 +465,26 @@ export async function updateOrderStatus( createdBy: userId ?? "system", createdByName: userName ?? "النظام", createdAt: now, - }) - .catch((err) => - console.error("[stock] Failed to log", movementType, "movement:", err), - ); + }), + ); } - await db - .update(orderProducts) - .set({ status: "returned", returnedQuantity: op.quantity }) - .where(eq(orderProducts.id, op.id)); + statements.push( + db + .update(orderProducts) + .set({ status: "returned", returnedQuantity: op.quantity }) + .where(eq(orderProducts.id, op.id)), + ); } } + // Atomic: status, history, driver credit, customer stats, restock, and line + // returns commit together or not at all. Without the batch, a mid-sequence + // failure committed "cancelled" without the restock — and the + // wasAlreadyTerminal guard then blocked every retry, permanently losing + // the inventory. + await db.batch(statements as [BatchStatement, ...BatchStatement[]]); + return true; } @@ -683,7 +710,13 @@ export async function syncOrderAfterCarrierUpdate( const patch: Record = { updatedAt: new Date().toISOString() }; if (fields.customerName !== undefined) patch.customerName = fields.customerName; if (fields.phone !== undefined) patch.phone = fields.phone; - if (fields.price !== undefined) patch.price = fields.price; + if (fields.price !== undefined) { + patch.price = fields.price; + // The COD the driver is booked to collect must follow the carrier + // amount — leaving codAmount stale made settlement and dashboards run + // on the old number while the carrier collected the new one. + patch.codAmount = sql`${fields.price} + ${orders.deliveryFee}`; + } await db.update(orders).set(patch).where(eq(orders.id, orderId)); } @@ -717,7 +750,7 @@ export async function clearOrderTracking(db: AppDb, orderId: string) { export async function deleteOrder(db: AppDb, orderId: string) { const now = new Date().toISOString(); - + // Get order details first to update customer stats const order = await db.select().from(orders).where(eq(orders.id, orderId)).get(); @@ -728,15 +761,50 @@ export async function deleteOrder(db: AppDb, orderId: string) { .where(eq(orderProducts.orderId, orderId)) .all(); - // Update customer stats BEFORE deleting the order + const statements: BatchStatement[] = []; + + // Update customer stats BEFORE deleting the order. + // totalOrders always drops (the order no longer exists). totalSpent is only + // subtracted when the order was still counting as spend: cancelled/returned + // orders already rolled their spend back at status-change time — + // subtracting again would double-decrement (e.g. cancel + delete). if (order) { - await db - .update(customers) - .set({ - totalOrders: sql`MAX(0, ${customers.totalOrders} - 1)`, - totalSpent: sql`MAX(0, ${customers.totalSpent} - ${order.price ?? 0})`, - }) - .where(eq(customers.id, order.customerId)); + const spendAlreadyRolledBack = + order.status === "cancelled" || order.status === "returned"; + + statements.push( + db + .update(customers) + .set({ + totalOrders: sql`MAX(0, ${customers.totalOrders} - 1)`, + ...(spendAlreadyRolledBack + ? {} + : { + totalSpent: sql`MAX(0, ${customers.totalSpent} - ${order.price ?? 0})`, + }), + }) + .where(eq(customers.id, order.customerId)), + ); + } + + // Reverse driver credit for delivered orders whose money has NOT been + // settled. Before this, deleting a delivered order left totalDelivered, + // totalEarnings and pendingCash permanently inflated — and unsettleable + // phantom cash (the order no longer appears in the pending list). + // Orders already linked to a payment keep the driver counters alone: + // the payment row is append-only history and must stay reconciled. + if (order && order.driverId && order.status === "delivered" && order.codPaymentId === null) { + statements.push( + db + .update(drivers) + .set({ + totalDelivered: sql`MAX(0, ${drivers.totalDelivered} - 1)`, + totalEarnings: sql`MAX(0, ${drivers.totalEarnings} - ${order.driverFee ?? 0})`, + pendingCash: sql`MAX(0, ${drivers.pendingCash} - ${order.codAmount ?? 0})`, + updatedAt: now, + }) + .where(eq(drivers.id, order.driverId)), + ); } // Restore inventory for products that track inventory @@ -763,14 +831,15 @@ export async function deleteOrder(db: AppDb, orderId: string) { const qtyBefore = variantRow?.inventory ?? 0; const qtyAfter = qtyBefore + remaining; - await db - .update(productVariants) - .set({ inventory: qtyAfter, updatedAt: now }) - .where(eq(productVariants.id, op.variantId)); + statements.push( + db + .update(productVariants) + .set({ inventory: qtyAfter, updatedAt: now }) + .where(eq(productVariants.id, op.variantId)), + ); - await db - .insert(stockMovements) - .values({ + statements.push( + db.insert(stockMovements).values({ id: crypto.randomUUID(), productId: op.productId, variantId: op.variantId, @@ -783,10 +852,8 @@ export async function deleteOrder(db: AppDb, orderId: string) { createdBy: "system", createdByName: "النظام", createdAt: now, - }) - .catch((err) => - console.error("[stock] Failed to log ORDER_CANCELLED movement:", err), - ); + }), + ); } else { // Restore product inventory const productInventoryRow = await db @@ -798,14 +865,15 @@ export async function deleteOrder(db: AppDb, orderId: string) { const qtyBefore = productInventoryRow?.inventory ?? 0; const qtyAfter = qtyBefore + remaining; - await db - .update(products) - .set({ inventory: qtyAfter, updatedAt: now }) - .where(eq(products.id, op.productId)); + statements.push( + db + .update(products) + .set({ inventory: qtyAfter, updatedAt: now }) + .where(eq(products.id, op.productId)), + ); - await db - .insert(stockMovements) - .values({ + statements.push( + db.insert(stockMovements).values({ id: crypto.randomUUID(), productId: op.productId, variantId: null, @@ -818,17 +886,28 @@ export async function deleteOrder(db: AppDb, orderId: string) { createdBy: "system", createdByName: "النظام", createdAt: now, - }) - .catch((err) => - console.error("[stock] Failed to log ORDER_CANCELLED movement:", err), - ); + }), + ); } } - // Delete related records (cascade will handle orderStatusHistory and reviews) - await db.delete(companyShipments).where(eq(companyShipments.orderId, orderId)); - await db.delete(orderProducts).where(eq(orderProducts.orderId, orderId)); - await db.delete(orders).where(eq(orders.id, orderId)); + // Delete related records. company_api_logs and webhook_events reference + // orders(id) with ON DELETE no action — they must be removed explicitly or + // the final orders delete fails the FOREIGN KEY constraint. Reviews and + // order_status_history cascade at the database level. + statements.push( + db.delete(companyApiLogs).where(eq(companyApiLogs.orderId, orderId)), + db.delete(webhookEvents).where(eq(webhookEvents.orderId, orderId)), + db.delete(companyShipments).where(eq(companyShipments.orderId, orderId)), + db.delete(orderProducts).where(eq(orderProducts.orderId, orderId)), + db.delete(orders).where(eq(orders.id, orderId)), + ); + + // Atomic: stats, restock, and deletes commit together or not at all. + // Without the batch, a mid-sequence failure left a gutted order behind + // (lines deleted, stats decremented, inventory restocked) while the + // order row itself survived. + await db.batch(statements as [BatchStatement, ...BatchStatement[]]); } // ─── Webhook Status Update ──────────────────────────────────────────────────── diff --git a/cod-shared/queries/products.ts b/cod-shared/queries/products.ts index 2d23261..26f5a68 100644 --- a/cod-shared/queries/products.ts +++ b/cod-shared/queries/products.ts @@ -1,8 +1,10 @@ import { eq, and, like, or, sum, isNull, sql, inArray, getTableColumns } from "drizzle-orm"; -import { products, productCategories, productVariants, productImages, reviews } from "../db/schema"; +import { products, productCategories, productVariants, productImages, reviews, stockMovements } from "../db/schema"; import type { AppDb } from "../db/client"; import { safeLikeTerm } from "./search"; +type BatchStatement = Parameters[0][number]; + export interface VariantOption { name: string; values: { value: string; hexColor?: string | null }[]; @@ -256,7 +258,43 @@ export async function updateProduct(db: AppDb, productId: string, data: UpdatePr if (data.storeFeatured !== undefined) updates.storeFeatured = data.storeFeatured; if (data.shippingProfileId !== undefined) updates.shippingProfileId = data.shippingProfileId ?? null; - await db.update(products).set(updates).where(eq(products.id, productId)); + const statements: BatchStatement[] = [ + db.update(products).set(updates).where(eq(products.id, productId)), + ]; + + // Simple-product inventory edits are manual adjustments — log them so the + // movement ledger keeps reconciling to real stock. Variant products keep + // their stock on variants; parent inventory is not ledger material. + if (data.inventory !== undefined) { + const current = await db + .select({ inventory: products.inventory, hasVariants: products.hasVariants, trackInventory: products.trackInventory }) + .from(products) + .where(eq(products.id, productId)) + .get(); + if (current?.trackInventory && !current.hasVariants) { + const delta = data.inventory - current.inventory; + if (delta !== 0) { + statements.push( + db.insert(stockMovements).values({ + id: crypto.randomUUID(), + productId, + variantId: null, + type: delta > 0 ? "ADJUSTMENT_ADD" : "ADJUSTMENT_REMOVE", + delta, + qtyBefore: current.inventory, + qtyAfter: data.inventory, + reason: "Product inventory edited", + reference: null, + createdBy: "system", + createdByName: "النظام", + createdAt: new Date().toISOString(), + }), + ); + } + } + } + + await db.batch(statements as [BatchStatement, ...BatchStatement[]]); return buildProductDetail(db, productId); } diff --git a/cod-shared/queries/store.ts b/cod-shared/queries/store.ts index 6776607..405c977 100644 --- a/cod-shared/queries/store.ts +++ b/cod-shared/queries/store.ts @@ -371,11 +371,23 @@ export async function findOrCreateCustomer( return row; } +/** + * Resolve the storefront delivery fee for a wilaya. + * + * Returns: + * - the fee (DZD, may be 0 for a legitimately-free price) when available + * - null when delivery is NOT available: a default profile exists but the + * wilaya has no rule, or the rule disables the requested delivery type. + * Callers must refuse the order — charging 0 silently would ship for free. + * - 0 when NO shipping profile exists at all (fresh store, no config) — + * mirrors the dashboard resolve-fee semantics (step 3: no profile, no + * restriction). + */ export async function getDeliveryFee( db: AppDb, wilayaId: number, deliveryType: "home" | "stop_desk", -): Promise { +): Promise { const profile = await db .select() .from(shippingProfiles) @@ -395,7 +407,9 @@ export async function getDeliveryFee( ) .get(); - if (!rule) return 0; + if (!rule) return null; + if (deliveryType === "home" && !rule.homeEnabled) return null; + if (deliveryType === "stop_desk" && !rule.stopDeskEnabled) return null; return deliveryType === "stop_desk" ? rule.stopDeskPrice : rule.homePrice; } @@ -650,6 +664,28 @@ export async function createStoreOrder( // ── Resolve phase (reads — no writes yet) ──────────────────────────────── + // Server-authoritative pricing: the catalog row is the ONLY source of the + // unit price. The client's pricePerUnit is display-only and NEVER trusted — + // it reaches this function over plain HTTP and is trivially editable. + const catalogPriceRow = await db + .select({ price: products.price, trackInventory: products.trackInventory }) + .from(products) + .where(and(eq(products.id, data.productId), isNull(products.deletedAt))) + .get(); + + const authoritativeUnitPrice = (() => { + if (data.variantSelections && data.variantSelections.length > 0) { + // Multi-variant: the order's price is the sum of per-variant prices. + // Variant prices are resolved per line below (lines carry them); the + // headline price is computed after lines are built. + return null; + } + if (data.variantId) { + return null; // resolved below from the variant row + } + return catalogPriceRow?.price ?? null; + })(); + const activeOffer = await selectApplicableOffer( db, data.productId, @@ -661,17 +697,17 @@ export async function createStoreOrder( const finalDeliveryFee = activeOffer?.discountType === "free_shipping" ? 0 : data.deliveryFee; - const price = data.quantity * data.pricePerUnit; - const lineRows: Array = []; if (data.variantSelections && data.variantSelections.length > 0) { + let linesPriceTotal = 0; for (const group of groupVariantSelections(data.variantSelections)) { - const varSkuRow = await db - .select({ sku: productVariants.sku }) + const varRow = await db + .select({ sku: productVariants.sku, price: productVariants.price }) .from(productVariants) .where(eq(productVariants.id, group.variantId)) .get(); + linesPriceTotal += (varRow?.price ?? 0) * group.count; lineRows.push({ id: crypto.randomUUID(), orderId: id, @@ -679,45 +715,64 @@ export async function createStoreOrder( productName: data.productName, variantId: group.variantId, variantLabel: group.variantLabel, - sku: varSkuRow?.sku ?? null, + sku: varRow?.sku ?? null, quantity: group.count, - pricePerUnit: data.pricePerUnit, - lineTotal: group.count * data.pricePerUnit, + pricePerUnit: varRow?.price ?? 0, + lineTotal: (varRow?.price ?? 0) * group.count, createdAt: now, }); } + (lineRows as any).__priceTotal = linesPriceTotal; + } else if (data.variantId) { + const varRow = await db + .select({ sku: productVariants.sku, price: productVariants.price }) + .from(productVariants) + .where(eq(productVariants.id, data.variantId)) + .get(); + const unitPrice = varRow?.price ?? authoritativeUnitPrice ?? 0; + lineRows.push({ + id: crypto.randomUUID(), + orderId: id, + productId: data.productId, + productName: data.productName, + variantId: data.variantId, + variantLabel: data.variantLabel, + sku: varRow?.sku ?? null, + quantity: data.quantity, + pricePerUnit: unitPrice, + lineTotal: unitPrice * data.quantity, + createdAt: now, + }); + (lineRows as any).__priceTotal = unitPrice * data.quantity; } else { let itemSku: string | null = null; - if (data.variantId) { - const varSkuRow = await db - .select({ sku: productVariants.sku }) - .from(productVariants) - .where(eq(productVariants.id, data.variantId)) - .get(); - itemSku = varSkuRow?.sku ?? null; - } else { - const prodSkuRow = await db - .select({ sku: products.sku }) - .from(products) - .where(eq(products.id, data.productId)) - .get(); - itemSku = prodSkuRow?.sku ?? null; - } + const prodSkuRow = await db + .select({ sku: products.sku }) + .from(products) + .where(eq(products.id, data.productId)) + .get(); + itemSku = prodSkuRow?.sku ?? null; + const unitPrice = authoritativeUnitPrice ?? 0; lineRows.push({ id: crypto.randomUUID(), orderId: id, productId: data.productId, productName: data.productName, - variantId: data.variantId ?? null, - variantLabel: data.variantLabel ?? null, + variantId: null, + variantLabel: null, sku: itemSku, quantity: data.quantity, - pricePerUnit: data.pricePerUnit, - lineTotal: price, + pricePerUnit: unitPrice, + lineTotal: unitPrice * data.quantity, createdAt: now, }); + (lineRows as any).__priceTotal = unitPrice * data.quantity; } + // The order's price is the catalog-derived sum of its lines — never the + // client-supplied quantity × pricePerUnit. + const price = (lineRows as any).__priceTotal as number; + let rewardLine: typeof orderProducts.$inferInsert | null = null; let rewardDeduct: DeductStockInput | null = null; diff --git a/cod-shared/queries/variants.ts b/cod-shared/queries/variants.ts index a0b984b..0c2b1f6 100644 --- a/cod-shared/queries/variants.ts +++ b/cod-shared/queries/variants.ts @@ -1,7 +1,9 @@ import { eq } from "drizzle-orm"; -import { productVariants, orderProducts } from "../db/schema"; +import { products, productVariants, orderProducts, stockMovements } from "../db/schema"; import type { AppDb } from "../db/client"; +type BatchStatement = Parameters[0][number]; + export interface CreateVariantData { variations: Record; price: number; @@ -36,6 +38,41 @@ function parseVariant(v: typeof productVariants.$inferSelect) { return { ...v, variations: JSON.parse(v.variations) as Record }; } +/** + * Ledger discipline: every tracked-SKU inventory change writes a movement row. + * Catalog edits (create/update/delete) previously mutated inventory with no + * ledger entry, so the movement log stopped reconciling to real stock. + * The activity log records WHO edited; the stock ledger records the math. + */ +function buildInventoryMovement( + db: AppDb, + input: { + productId: string; + variantId: string | null; + delta: number; + qtyBefore: number; + qtyAfter: number; + reason: string; + }, +): BatchStatement { + return db + .insert(stockMovements) + .values({ + id: crypto.randomUUID(), + productId: input.productId, + variantId: input.variantId, + type: input.delta > 0 ? "ADJUSTMENT_ADD" : "ADJUSTMENT_REMOVE", + delta: input.delta, + qtyBefore: input.qtyBefore, + qtyAfter: input.qtyAfter, + reason: input.reason, + reference: null, + createdBy: "system", + createdByName: "النظام", + createdAt: new Date().toISOString(), + }); +} + export async function getVariantsByProduct(db: AppDb, productId: string) { const variants = await db .select() @@ -55,25 +92,51 @@ export async function createVariant(db: AppDb, productId: string, data: CreateVa const id = crypto.randomUUID(); const now = new Date().toISOString(); - await db.insert(productVariants).values({ - id, - productId, - variations: JSON.stringify(data.variations), - currency: "DZD", - price: data.price, - compareAtPrice: data.compareAtPrice ?? null, - sku: data.sku ?? null, - barcode: data.barcode ?? null, - inventory: data.inventory, - lowStockThreshold: data.lowStockThreshold ?? 5, - weightKg: data.weightKg ?? null, - imageId: data.imageId ?? null, - isDefault: data.isDefault, - active: data.active, - position: data.position, - createdAt: now, - updatedAt: now, - }); + const statements: BatchStatement[] = [ + db.insert(productVariants).values({ + id, + productId, + variations: JSON.stringify(data.variations), + currency: "DZD", + price: data.price, + compareAtPrice: data.compareAtPrice ?? null, + sku: data.sku ?? null, + barcode: data.barcode ?? null, + inventory: data.inventory, + lowStockThreshold: data.lowStockThreshold ?? 5, + weightKg: data.weightKg ?? null, + imageId: data.imageId ?? null, + isDefault: data.isDefault, + active: data.active, + position: data.position, + createdAt: now, + updatedAt: now, + }), + ]; + + // Opening stock enters the ledger for tracked products — the audit trail + // must reconcile to inventory from the variant's first day. + if (data.inventory > 0) { + const productRow = await db + .select({ trackInventory: products.trackInventory }) + .from(products) + .where(eq(products.id, productId)) + .get(); + if (productRow?.trackInventory) { + statements.push( + buildInventoryMovement(db, { + productId, + variantId: id, + delta: data.inventory, + qtyBefore: 0, + qtyAfter: data.inventory, + reason: "Opening stock — variant created", + }), + ); + } + } + + await db.batch(statements as [BatchStatement, ...BatchStatement[]]); return getVariantById(db, id); } @@ -94,13 +157,65 @@ export async function updateVariant(db: AppDb, variantId: string, data: UpdateVa if (data.active !== undefined) updates.active = data.active; if (data.position !== undefined) updates.position = data.position; - await db.update(productVariants).set(updates).where(eq(productVariants.id, variantId)); + const statements: BatchStatement[] = [ + db.update(productVariants).set(updates).where(eq(productVariants.id, variantId)), + ]; + + // Direct inventory edits are manual adjustments — log them like one. + if (data.inventory !== undefined) { + const variantRow = await db + .select({ productId: productVariants.productId, inventory: productVariants.inventory }) + .from(productVariants) + .where(eq(productVariants.id, variantId)) + .get(); + if (variantRow) { + const productRow = await db + .select({ trackInventory: products.trackInventory }) + .from(products) + .where(eq(products.id, variantRow.productId)) + .get(); + if (productRow?.trackInventory) { + const qtyBefore = variantRow.inventory; + const delta = data.inventory - qtyBefore; + if (delta !== 0) { + statements.push( + buildInventoryMovement(db, { + productId: variantRow.productId, + variantId, + delta, + qtyBefore, + qtyAfter: data.inventory, + reason: "Variant inventory edited", + }), + ); + } + } + } + } + + await db.batch(statements as [BatchStatement, ...BatchStatement[]]); return getVariantById(db, variantId); } export async function deleteVariant(db: AppDb, variantId: string) { // Preserve order history — null out the reference rather than blocking deletion. - await db.update(orderProducts).set({ variantId: null }).where(eq(orderProducts.variantId, variantId)); - await db.delete(productVariants).where(eq(productVariants.id, variantId)); + const variantRow = await db + .select({ productId: productVariants.productId, inventory: productVariants.inventory }) + .from(productVariants) + .where(eq(productVariants.id, variantId)) + .get(); + + // No ledger surrogate for the deletion: stock_movements.variant_id is + // ON DELETE cascade, so the variant's scoped movements (including its + // opening stock) leave with the row. Σ(movements) stays reconciled to the + // tracked pool; a productId-level -N row would double-count against the + // vanished +N. The atomic batch still guarantees the null-out and the + // delete commit together. + const statements: BatchStatement[] = [ + db.update(orderProducts).set({ variantId: null }).where(eq(orderProducts.variantId, variantId)), + db.delete(productVariants).where(eq(productVariants.id, variantId)), + ]; + + await db.batch(statements as [BatchStatement, ...BatchStatement[]]); return { success: true }; }