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