diff --git a/cod-astro/theme01/src/core/api/types.ts b/cod-astro/theme01/src/core/api/types.ts
index 377ac36..a574d9d 100644
--- a/cod-astro/theme01/src/core/api/types.ts
+++ b/cod-astro/theme01/src/core/api/types.ts
@@ -36,6 +36,7 @@ export interface StoreConfig {
otpEnabled: boolean;
status: "active" | "inactive";
pixelId?: string | null;
+ conversionEvent?: "Purchase" | "Purchase_Confirmed" | "Purchase_Delivered" | "Lead" | null;
}
export interface ShippingRates {
diff --git a/cod-astro/theme01/src/pages/thank-you.astro b/cod-astro/theme01/src/pages/thank-you.astro
index 577ed63..9b6088a 100644
--- a/cod-astro/theme01/src/pages/thank-you.astro
+++ b/cod-astro/theme01/src/pages/thank-you.astro
@@ -17,6 +17,15 @@ if (!orderNumber) {
}
const pixelId = config.pixelId ?? null;
+// At checkout stage, only "Purchase" (instant mode) and "Lead" (lead mode) fire on the thank-you page.
+// "Purchase_Confirmed" and "Purchase_Delivered" deliberately fire downstream via server CAPI only.
+const rawMode = config.conversionEvent ?? "Purchase";
+const checkoutEvent =
+ rawMode === "Purchase"
+ ? "Purchase"
+ : rawMode === "Lead"
+ ? "Lead"
+ : null;
const totalNum = total ? parseInt(total) : null;
const steps = [
@@ -108,16 +117,17 @@ const steps = [
-{pixelId && (
+{pixelId && checkoutEvent && (
)}
diff --git a/cod-astro/theme01/src/theme/config/store.ts b/cod-astro/theme01/src/theme/config/store.ts
index 4d5d61f..0ab8198 100644
--- a/cod-astro/theme01/src/theme/config/store.ts
+++ b/cod-astro/theme01/src/theme/config/store.ts
@@ -36,6 +36,7 @@ export const DEFAULT_CONFIG: StoreConfig = {
otpEnabled: false,
status: "active",
pixelId: null,
+ conversionEvent: null,
};
export interface StoreContext {
diff --git a/cod-client-astro/locales/ar/settings.json b/cod-client-astro/locales/ar/settings.json
index beea17f..bdc9414 100644
--- a/cod-client-astro/locales/ar/settings.json
+++ b/cod-client-astro/locales/ar/settings.json
@@ -55,10 +55,14 @@
"tracking_ad_account_hint": "تسمية خاصة بك لحساب الإعلانات على Meta — للمرجعية فقط",
"tracking_ad_account_placeholder": "مثال: متجري — الحساب الرئيسي",
"tracking_event_label": "حدث التحويل",
+ "tracking_event_purchase_instant_label": "Purchase فوري — عند تسجيل الطلب (موصى به لإعلانات فيسبوك)",
+ "tracking_event_purchase_instant_hint": "يُطلق حدث الشراء فوراً عند إتمام الطلب مع المبلغ ومعرف إلغاء الازدواجية. ضروري لتحسين خوارزمية إعلانات Meta في الوقت الفعلي.",
+ "tracking_event_purchase_confirmed_label": "Purchase — عند التأكيد الهاتفي (مؤكد)",
+ "tracking_event_purchase_confirmed_hint": "يُطلق حدث الشراء فقط عندما يؤكد فريقك الطلب هاتفياً مع العميل. يفلتر 100% من الطلبات الوهمية مع البقاء ضمن نافذة التعلم لـ Meta (خلال 24 ساعة).",
+ "tracking_event_purchase_delivered_label": "Purchase — عند التوصيل والاستلام (مُسلّم)",
+ "tracking_event_purchase_delivered_hint": "يُحسّن تتبع الإعلانات نحو الطلبات المستلمة. بالنسبة للولايات البعيدة، قد يُرسل الحدث عند انطلاق التوصيل لمراعاة فترات الشحن الأطول.",
"tracking_event_lead_label": "Lead — عند تسجيل الطلب",
- "tracking_event_lead_hint": "يُطلق لحظة تقديم الطلب. إشارة أسرع لإعلاناتك، لكن الطلبات غير المؤكدة تُحتسب ضمنه.",
- "tracking_event_purchase_label": "Purchase — عند التسليم المؤكد",
- "tracking_event_purchase_hint": "يُطلق فقط عند تسليم الطلب ودفع قيمته. إشارة أبطأ، لكنها تطابق الإيرادات الحقيقية.",
+ "tracking_event_lead_hint": "يُطلق حدث Lead فور تسجيل الطلب. اختر هذا الخيار إذا كانت حملتك الإعلانية على Meta محسّنة لجمع بيانات العملاء (Leads).",
"tracking_test_mode_label": "الوضع التجريبي",
"tracking_test_mode_hint": "يرسل أحداث API التحويلات إلى مسار الاختبار لدى Meta بدلاً من قياس الإنتاج",
"tracking_test_mode_warning": "الوضع التجريبي مفعّل — الأحداث لا تُحتسب في القياس الفعلي. عطّله قبل الانطلاق.",
diff --git a/cod-client-astro/locales/en/settings.json b/cod-client-astro/locales/en/settings.json
index d3ad8d5..c5f80b6 100644
--- a/cod-client-astro/locales/en/settings.json
+++ b/cod-client-astro/locales/en/settings.json
@@ -55,10 +55,14 @@
"tracking_ad_account_hint": "Your own label for the Meta ad account — for reference only",
"tracking_ad_account_placeholder": "e.g. My Store — Main Account",
"tracking_event_label": "Conversion Event",
- "tracking_event_lead_label": "Lead — at order placement",
- "tracking_event_lead_hint": "Fires the moment an order is placed. Faster signal for your ads, but unconfirmed orders are included.",
- "tracking_event_purchase_label": "Purchase — at confirmed delivery",
- "tracking_event_purchase_hint": "Fires only when the order is delivered and paid. Slower signal, but matches real revenue.",
+ "tracking_event_purchase_instant_label": "Instant Purchase — at checkout (Recommended for Meta Ads)",
+ "tracking_event_purchase_instant_hint": "Fires Purchase immediately upon checkout with order total and deduplication ID. Essential for Facebook Ads real-time conversion optimization.",
+ "tracking_event_purchase_confirmed_label": "Purchase — on phone confirmation (confirmed)",
+ "tracking_event_purchase_confirmed_hint": "Fires Purchase only when your call center confirms the order with the customer. Filters out 100% of fake leads while staying within Meta's 24h attribution window.",
+ "tracking_event_purchase_delivered_label": "Purchase — on delivery (delivered)",
+ "tracking_event_purchase_delivered_hint": "Optimizes toward delivered orders. For selected remote areas, the event may be sent when delivery begins to account for longer delivery transit times.",
+ "tracking_event_lead_label": "Lead — at checkout",
+ "tracking_event_lead_hint": "Fires a Lead event immediately upon order placement. Select this if your Meta ad campaign optimizes for Leads instead of Purchases.",
"tracking_test_mode_label": "Test Mode",
"tracking_test_mode_hint": "Sends Conversions API events to Meta's test stream instead of production measurement",
"tracking_test_mode_warning": "Test mode is on — events are not counted for real measurement. Turn it off before going live.",
diff --git a/cod-client-astro/locales/fr/settings.json b/cod-client-astro/locales/fr/settings.json
index 443e004..c2674ef 100644
--- a/cod-client-astro/locales/fr/settings.json
+++ b/cod-client-astro/locales/fr/settings.json
@@ -55,10 +55,14 @@
"tracking_ad_account_hint": "Votre propre libellé pour le compte publicitaire Meta — à titre de référence",
"tracking_ad_account_placeholder": "ex: Ma Boutique — Compte principal",
"tracking_event_label": "Événement de conversion",
+ "tracking_event_purchase_instant_label": "Purchase instantané — dès la commande (Recommandé pour Meta Ads)",
+ "tracking_event_purchase_instant_hint": "Déclenche l'événement Purchase dès la commande avec le montant du panier et l'identifiant dédoublonné. Essentiel pour l'optimisation publicitaire en temps réel.",
+ "tracking_event_purchase_confirmed_label": "Purchase — à la confirmation téléphonique (confirmé)",
+ "tracking_event_purchase_confirmed_hint": "Déclenche l'événement Purchase uniquement lorsque votre équipe confirme la commande par téléphone. Élimine 100% des faux numéros tout en restant dans la fenêtre de 24h de Meta.",
+ "tracking_event_purchase_delivered_label": "Purchase — à la livraison (livré)",
+ "tracking_event_purchase_delivered_hint": "Optimise vers les commandes livrées. Pour certaines wilayas éloignées, l'événement peut être envoyé au début de la livraison pour tenir compte des délais d'acheminement.",
"tracking_event_lead_label": "Lead — dès la commande",
- "tracking_event_lead_hint": "Déclenché dès qu'une commande est passée. Signal plus rapide pour vos publicités, mais les commandes non confirmées sont incluses.",
- "tracking_event_purchase_label": "Purchase — à la livraison confirmée",
- "tracking_event_purchase_hint": "Déclenché uniquement lorsque la commande est livrée et payée. Signal plus lent, mais conforme au revenu réel.",
+ "tracking_event_lead_hint": "Déclenche l'événement Lead dès la commande. Choisissez cette option si votre campagne Meta Ads optimise pour les Prospects (Leads).",
"tracking_test_mode_label": "Mode test",
"tracking_test_mode_hint": "Envoie les événements de l'API Conversions vers le flux de test de Meta au lieu de la production",
"tracking_test_mode_warning": "Le mode test est activé — les événements ne comptent pas pour la mesure réelle. Désactivez-le avant de lancer.",
diff --git a/cod-client-astro/src/features/settings/components/TrackingSettings.tsx b/cod-client-astro/src/features/settings/components/TrackingSettings.tsx
index b6c057f..4706f07 100644
--- a/cod-client-astro/src/features/settings/components/TrackingSettings.tsx
+++ b/cod-client-astro/src/features/settings/components/TrackingSettings.tsx
@@ -5,11 +5,13 @@ import { useT } from "@/i18n/react";
import { getPixelConfig, savePixelConfig } from "@/features/settings/api";
import { FieldRow, SettingsSection } from "@/features/settings/components/SettingsSection";
-type ConversionEvent = "Lead" | "Purchase";
+type ConversionEvent = "Purchase" | "Purchase_Confirmed" | "Purchase_Delivered" | "Lead";
const EVENT_OPTIONS: { value: ConversionEvent; labelKey: string; hintKey: string }[] = [
+ { value: "Purchase", labelKey: "store.tracking_event_purchase_instant_label", hintKey: "store.tracking_event_purchase_instant_hint" },
+ { value: "Purchase_Confirmed", labelKey: "store.tracking_event_purchase_confirmed_label", hintKey: "store.tracking_event_purchase_confirmed_hint" },
+ { value: "Purchase_Delivered", labelKey: "store.tracking_event_purchase_delivered_label", hintKey: "store.tracking_event_purchase_delivered_hint" },
{ value: "Lead", labelKey: "store.tracking_event_lead_label", hintKey: "store.tracking_event_lead_hint" },
- { value: "Purchase", labelKey: "store.tracking_event_purchase_label", hintKey: "store.tracking_event_purchase_hint" },
];
export function TrackingSettings() {
diff --git a/cod-client-astro/src/features/settings/types.ts b/cod-client-astro/src/features/settings/types.ts
index 7213275..40d6091 100644
--- a/cod-client-astro/src/features/settings/types.ts
+++ b/cod-client-astro/src/features/settings/types.ts
@@ -57,7 +57,7 @@ export interface PixelConfig {
adAccountName: string | null;
accessTokenMasked: string;
testEventCode: string | null;
- conversionEvent: "Lead" | "Purchase";
+ conversionEvent: "Purchase" | "Purchase_Confirmed" | "Purchase_Delivered" | "Lead";
testMode: boolean;
enabled: boolean;
createdAt: string;
@@ -69,7 +69,7 @@ export interface SavePixelConfigData {
adAccountName?: string | null;
accessToken?: string;
testEventCode?: string | null;
- conversionEvent: "Lead" | "Purchase";
+ conversionEvent: "Purchase" | "Purchase_Confirmed" | "Purchase_Delivered" | "Lead";
testMode?: boolean;
enabled?: boolean;
}
diff --git a/cod-server/src/db/migrations/0022_capi_event_claim.sql b/cod-server/src/db/migrations/0022_capi_event_claim.sql
new file mode 100644
index 0000000..f71d1f2
--- /dev/null
+++ b/cod-server/src/db/migrations/0022_capi_event_claim.sql
@@ -0,0 +1,5 @@
+-- CAPI atomic event claim — unique business key per order + stage + event_name.
+-- Prevents duplicate server events when multiple trigger sources (e.g. carrier
+-- webhook retries, concurrent dashboard status transitions) fire concurrently.
+ALTER TABLE `capi_event_log` ADD `stage` text NOT NULL DEFAULT 'delivered';--> statement-breakpoint
+CREATE UNIQUE INDEX IF NOT EXISTS `idx_capi_event_log_claim` ON `capi_event_log` (`order_id`, `stage`, `event_name`);
diff --git a/cod-server/src/db/migrations/0023_legacy_conversion_event_semantics.sql b/cod-server/src/db/migrations/0023_legacy_conversion_event_semantics.sql
new file mode 100644
index 0000000..87b036f
--- /dev/null
+++ b/cod-server/src/db/migrations/0023_legacy_conversion_event_semantics.sql
@@ -0,0 +1,7 @@
+-- Legacy conversion-event semantics: rows created before the 4-tier model used
+-- 'Purchase' to mean "Purchase at confirmed delivery" (webhook-driven). The
+-- 4-tier model redefines 'Purchase' as instant checkout Purchase, so existing
+-- selections must be preserved as 'Purchase_Delivered' — otherwise stores that
+-- chose on-delivery conversion silently switch to instant-checkout measurement.
+-- New stores (and re-selections after this migration) use the new meanings.
+UPDATE `store_pixel_config` SET `conversion_event` = 'Purchase_Delivered' WHERE `conversion_event` = 'Purchase';--> statement-breakpoint
\ No newline at end of file
diff --git a/cod-server/src/endpoints/orders/orders.test.ts b/cod-server/src/endpoints/orders/orders.test.ts
index 0216fc8..0e66d8a 100644
--- a/cod-server/src/endpoints/orders/orders.test.ts
+++ b/cod-server/src/endpoints/orders/orders.test.ts
@@ -53,6 +53,9 @@ vi.mock("@/lib/activity", () => ({
}));
vi.mock("@/workflows/capi-helpers", () => ({
shouldTriggerCapiPurchase: vi.fn(() => false),
+ shouldTriggerCapiConfirmed: vi.fn(() => false),
+ getCapiWorkflowId: vi.fn((id: string, stage: string, event: string) => `capi-${id}-${stage}-${event}`),
+ resolveConversionForStage: vi.fn(() => ({ shouldFire: false })),
resolveCapiDispatch: vi.fn(() => ({ send: false, reason: "tracking-disabled", message: "mock skip" })),
}));
vi.mock("@/endpoints/delivery-companies/queries");
diff --git a/cod-server/src/endpoints/orders/routes.test.ts b/cod-server/src/endpoints/orders/routes.test.ts
index 5e7b10b..165d64a 100644
--- a/cod-server/src/endpoints/orders/routes.test.ts
+++ b/cod-server/src/endpoints/orders/routes.test.ts
@@ -32,6 +32,9 @@ vi.mock("@/lib/activity", () => ({
}));
vi.mock("@/workflows/capi-helpers", () => ({
shouldTriggerCapiPurchase: vi.fn(() => false),
+ shouldTriggerCapiConfirmed: vi.fn(() => false),
+ getCapiWorkflowId: vi.fn((id: string, stage: string, event: string) => `capi-${id}-${stage}-${event}`),
+ resolveConversionForStage: vi.fn(() => ({ shouldFire: false })),
resolveCapiDispatch: vi.fn(() => ({ send: false, reason: "tracking-disabled", message: "mock skip" })),
}));
vi.mock("@/endpoints/delivery-companies/queries", () => ({
diff --git a/cod-server/src/endpoints/orders/status-transitions.ts b/cod-server/src/endpoints/orders/status-transitions.ts
index 2c228cb..69f3d06 100644
--- a/cod-server/src/endpoints/orders/status-transitions.ts
+++ b/cod-server/src/endpoints/orders/status-transitions.ts
@@ -14,7 +14,7 @@ import * as validation from "./validation";
import { logActivity, ACTIONS } from "@/lib/activity";
import { NotFoundError, BusinessLogicError, ValidationError } from "@/lib/errors/classes";
import { ERROR_CODES, ERROR_CATEGORIES } from "../../../../cod-shared/errors/codes";
-import { shouldTriggerCapiPurchase } from "@/workflows/capi-helpers";
+import { shouldTriggerCapiPurchase, shouldTriggerCapiConfirmed, getCapiWorkflowId } from "@/workflows/capi-helpers";
/**
* PATCH /orders/:id/status
@@ -78,18 +78,24 @@ export async function updateStatus(c: Context) {
// Fire CAPI Purchase Workflow — never blocks the status response.
// waitUntil: the runtime cancels un-awaited promises after the response,
// which would silently drop the workflow creation.
- if (shouldTriggerCapiPurchase(validated.status, order.wilayaId)) {
+ const isDeliveredTrigger = shouldTriggerCapiPurchase(validated.status, order.wilayaId);
+ const isConfirmedTrigger = shouldTriggerCapiConfirmed(validated.status);
+
+ if (isDeliveredTrigger || isConfirmedTrigger) {
if (!c.env.CAPI_WORKFLOW) {
// Binding absent — worker was provisioned before CAPI_WORKFLOW was added.
// Re-provision the client to activate the binding.
console.error("[capi-workflow] CAPI_WORKFLOW binding is undefined — worker needs re-provision");
} else {
+ const stage = isConfirmedTrigger ? "confirmed" : "delivered";
+ const workflowId = getCapiWorkflowId(orderId, stage, "Purchase");
c.executionCtx.waitUntil(
c.env.CAPI_WORKFLOW.create({
- id: `capi-${orderId}-Purchase`,
+ id: workflowId,
params: {
orderId,
eventName: "Purchase",
+ stage,
triggeredAt: Math.floor(Date.now() / 1000),
triggerStatus: validated.status,
},
diff --git a/cod-server/src/endpoints/store/capi-lead-trigger.test.ts b/cod-server/src/endpoints/store/capi-lead-trigger.test.ts
index c2c9e16..09e2253 100644
--- a/cod-server/src/endpoints/store/capi-lead-trigger.test.ts
+++ b/cod-server/src/endpoints/store/capi-lead-trigger.test.ts
@@ -98,9 +98,9 @@ beforeEach(() => {
stubSuccessfulOrderFlow();
});
-describe("CAPI Lead workflow trigger", () => {
- it("creates a durable Lead workflow for every order, via waitUntil", async () => {
- const workflow = { create: vi.fn(async () => ({ id: "capi-ord-1-Lead" })) };
+describe("CAPI checkout workflow trigger", () => {
+ it("creates a durable CAPI workflow for order at checkout, via waitUntil", async () => {
+ const workflow = { create: vi.fn(async () => ({ id: "capi-ord-1-checkout-Purchase" })) };
const before = Math.floor(Date.now() / 1000);
const res = await placeOrder(makeApp(workflow));
@@ -111,9 +111,10 @@ describe("CAPI Lead workflow trigger", () => {
{ id: string; params: Record }
];
const { id, params } = call[0];
- expect(id).toBe("capi-ord-1-Lead");
+ expect(id).toBe("capi-ord-1-checkout-Purchase");
expect(params.orderId).toBe("ord-1");
- expect(params.eventName).toBe("Lead");
+ expect(params.eventName).toBe("Purchase");
+ expect(params.stage).toBe("checkout");
expect(params.triggerStatus).toBe("order_created");
expect(params.eventSourceUrl).toBe("https://shop.example/prod");
expect(params.triggeredAt).toBeGreaterThanOrEqual(before);
diff --git a/cod-server/src/endpoints/store/handlers.ts b/cod-server/src/endpoints/store/handlers.ts
index 56277a1..b1ef90e 100644
--- a/cod-server/src/endpoints/store/handlers.ts
+++ b/cod-server/src/endpoints/store/handlers.ts
@@ -6,6 +6,10 @@ import { storeOrderSchema, storeReviewSchema } from "./validation";
import { NotFoundError, ValidationError, ConflictError, BusinessLogicError } from "@/lib/errors/classes";
import { ERROR_CODES } from "../../../../cod-shared/errors/codes";
import { assertOtpVerification } from "./otp-gate";
+import { getPixelConfig } from "../../../../cod-shared/queries/pixel-config";
+import { resolveConversionForStage, getCapiWorkflowId } from "@/workflows/capi-helpers";
+import { stores } from "../../../../cod-shared/db/schema";
+import { eq } from "drizzle-orm";
export async function getStoreConfig(c: Context) {
const storeId = c.get("storeId")!;
@@ -203,26 +207,62 @@ export async function createStoreOrder(c: Context) {
userAgent,
});
- // CAPI Lead event — durable Workflow (same path as Purchase). waitUntil:
- // the Workers runtime cancels un-awaited promises once the response is
- // sent, which would silently drop the workflow creation. The workflow
- // gates on the merchant's conversion-event choice, retries Meta 5xx, and
- // audit-logs every outcome. A failure can never block order confirmation.
+ // Meta CAPI conversion event at checkout — evaluated against merchant's tracking mode.
+ // When mode is instant "Purchase", sends Purchase (matching the thank-you Pixel).
+ // When mode is "Lead", sends Lead (matching the thank-you Pixel).
+ // When mode is "Purchase_Confirmed" or "Purchase_Delivered", skips at checkout
+ // and fires down-funnel via server CAPI.
if (c.env.CAPI_WORKFLOW) {
- c.executionCtx.waitUntil(
- c.env.CAPI_WORKFLOW.create({
- id: `capi-${order.id}-Lead`,
- params: {
- orderId: order.id,
- eventName: "Lead",
- triggeredAt: Math.floor(Date.now() / 1000),
- triggerStatus: "order_created",
- eventSourceUrl: c.req.header("Referer") ?? undefined,
- },
- }).catch((err: unknown) =>
- console.error("[capi-workflow] lead trigger failed:", (err as Error)?.message)
- )
- );
+ try {
+ const storeId = c.get("storeId");
+ const pixelConfig =
+ storeId && typeof db.select === "function"
+ ? await getPixelConfig(db, storeId)
+ : undefined;
+ const decision = resolveConversionForStage(pixelConfig?.conversionEvent, "checkout");
+
+ if (decision.shouldFire && decision.eventName) {
+ let storeRow: { domain: string | null } | undefined = undefined;
+ if (storeId && typeof db.select === "function") {
+ storeRow = await db
+ .select({ domain: stores.domain })
+ .from(stores)
+ .where(eq(stores.id, storeId))
+ .get();
+ }
+
+ let eventSourceUrl: string | undefined = storeRow?.domain
+ ? `https://${storeRow.domain}/thank-you`
+ : undefined;
+
+ if (!eventSourceUrl) {
+ const referer = c.req.header("Referer");
+ if (referer && (referer.startsWith("http://") || referer.startsWith("https://"))) {
+ eventSourceUrl = referer;
+ }
+ }
+
+ const workflowId = getCapiWorkflowId(order.id, "checkout", decision.eventName);
+
+ c.executionCtx.waitUntil(
+ c.env.CAPI_WORKFLOW.create({
+ id: workflowId,
+ params: {
+ orderId: order.id,
+ eventName: decision.eventName,
+ stage: "checkout",
+ triggeredAt: Math.floor(Date.now() / 1000),
+ triggerStatus: "order_created",
+ eventSourceUrl,
+ },
+ }).catch((err: unknown) =>
+ console.error(`[capi-workflow] checkout ${decision.eventName} trigger failed:`, (err as Error)?.message)
+ )
+ );
+ }
+ } catch (err) {
+ console.error("[capi-workflow] checkout evaluation failed:", (err as Error)?.message);
+ }
} else {
console.error("[capi-workflow] CAPI_WORKFLOW binding is undefined — worker needs re-provision");
}
diff --git a/cod-server/src/endpoints/store/otp-gate.test.ts b/cod-server/src/endpoints/store/otp-gate.test.ts
index e88cea4..406c64d 100644
--- a/cod-server/src/endpoints/store/otp-gate.test.ts
+++ b/cod-server/src/endpoints/store/otp-gate.test.ts
@@ -25,6 +25,9 @@ vi.mock("@/db", () => ({ getDb: vi.fn(() => ({})) }));
vi.mock("./queries");
vi.mock("@/workflows/capi-helpers", () => ({
shouldTriggerCapiPurchase: vi.fn(() => false),
+ shouldTriggerCapiConfirmed: vi.fn(() => false),
+ getCapiWorkflowId: vi.fn((id: string, stage: string, event: string) => `capi-${id}-${stage}-${event}`),
+ resolveConversionForStage: vi.fn(() => ({ shouldFire: false })),
resolveCapiDispatch: vi.fn(() => ({ send: false, reason: "tracking-disabled", message: "mock skip" })),
}));
vi.mock("../../../../cod-shared/queries/otp-config");
diff --git a/cod-server/src/endpoints/stores/handlers.ts b/cod-server/src/endpoints/stores/handlers.ts
index 36f3b72..d5d2a73 100644
--- a/cod-server/src/endpoints/stores/handlers.ts
+++ b/cod-server/src/endpoints/stores/handlers.ts
@@ -45,7 +45,7 @@ const pixelConfigSchema = z.object({
adAccountName: z.string().max(200).nullable().optional(),
accessToken: z.string().default(""),
testEventCode: z.string().nullable().optional(),
- conversionEvent: z.enum(["Lead", "Purchase"]),
+ conversionEvent: z.enum(["Purchase", "Purchase_Confirmed", "Purchase_Delivered", "Lead"]),
testMode: z.boolean().optional(),
enabled: z.boolean().optional(),
});
diff --git a/cod-server/src/endpoints/stores/routes.ts b/cod-server/src/endpoints/stores/routes.ts
index 4b04c6f..4cd10ef 100644
--- a/cod-server/src/endpoints/stores/routes.ts
+++ b/cod-server/src/endpoints/stores/routes.ts
@@ -55,7 +55,7 @@ const savePixelBodySchema = z.object({
"Meta access token. Empty string keeps the previously stored token (the token is never sent back to the client).",
}),
testEventCode: z.string().nullable().optional(),
- conversionEvent: z.enum(["Lead", "Purchase"]),
+ conversionEvent: z.enum(["Purchase", "Purchase_Confirmed", "Purchase_Delivered", "Lead"]),
testMode: z.boolean().optional(),
enabled: z.boolean().optional(),
});
diff --git a/cod-server/src/endpoints/webhooks/handlers.ts b/cod-server/src/endpoints/webhooks/handlers.ts
index 53a4c5a..ffd5de3 100644
--- a/cod-server/src/endpoints/webhooks/handlers.ts
+++ b/cod-server/src/endpoints/webhooks/handlers.ts
@@ -41,7 +41,7 @@ function isOrderStatus(value: string): value is (typeof ORDER_STATUSES)[number]
/** Terminal order statuses — late carrier events never modify these orders. */
const TERMINAL_ORDER_STATUSES = new Set(["delivered", "returned", "cancelled"]);
-import { shouldTriggerCapiPurchase } from "@/workflows/capi-helpers";
+import { shouldTriggerCapiPurchase, getCapiWorkflowId } from "@/workflows/capi-helpers";
// ─── ZR Express ───────────────────────────────────────────────────────────────
@@ -206,8 +206,14 @@ export async function handleZrWebhook(c: Context) {
} else {
c.executionCtx.waitUntil(
c.env.CAPI_WORKFLOW.create({
- id: `capi-${resolvedOrder.id}-Purchase`,
- params: { orderId: resolvedOrder.id, eventName: "Purchase", triggeredAt: Math.floor(Date.now() / 1000), triggerStatus: newStatus },
+ id: getCapiWorkflowId(resolvedOrder.id, "delivered", "Purchase"),
+ params: {
+ orderId: resolvedOrder.id,
+ eventName: "Purchase",
+ stage: "delivered",
+ triggeredAt: Math.floor(Date.now() / 1000),
+ triggerStatus: newStatus,
+ },
}).catch((err: unknown) => console.error("[capi-workflow] zr trigger failed:", (err as Error)?.message))
);
}
@@ -450,8 +456,14 @@ export async function handleYalidineWebhook(c: Context) {
} else {
c.executionCtx.waitUntil(
c.env.CAPI_WORKFLOW.create({
- id: `capi-${order.id}-Purchase`,
- params: { orderId: order.id, eventName: "Purchase", triggeredAt: Math.floor(Date.now() / 1000), triggerStatus: nextStatus },
+ id: getCapiWorkflowId(order.id, "delivered", "Purchase"),
+ params: {
+ orderId: order.id,
+ eventName: "Purchase",
+ stage: "delivered",
+ triggeredAt: Math.floor(Date.now() / 1000),
+ triggerStatus: nextStatus,
+ },
}).catch((err: unknown) => console.error("[capi-workflow] yalidine trigger failed:", (err as Error)?.message))
);
}
diff --git a/cod-server/src/endpoints/webhooks/webhooks.test.ts b/cod-server/src/endpoints/webhooks/webhooks.test.ts
index f981132..5eac780 100644
--- a/cod-server/src/endpoints/webhooks/webhooks.test.ts
+++ b/cod-server/src/endpoints/webhooks/webhooks.test.ts
@@ -66,6 +66,9 @@ vi.mock("./yalidine-status-mapper", () => ({
vi.mock("@/workflows/capi-helpers", () => ({
shouldTriggerCapiPurchase: vi.fn().mockReturnValue(false),
+ shouldTriggerCapiConfirmed: vi.fn().mockReturnValue(false),
+ getCapiWorkflowId: vi.fn((id: string, stage: string, event: string) => `capi-${id}-${stage}-${event}`),
+ resolveConversionForStage: vi.fn(() => ({ shouldFire: false })),
resolveCapiDispatch: vi.fn(() => ({ send: false, reason: "tracking-disabled", message: "mock skip" })),
}));
diff --git a/cod-server/src/endpoints/webhooks/yalidine-webhook.e2e.test.ts b/cod-server/src/endpoints/webhooks/yalidine-webhook.e2e.test.ts
index 956c1d2..7065448 100644
--- a/cod-server/src/endpoints/webhooks/yalidine-webhook.e2e.test.ts
+++ b/cod-server/src/endpoints/webhooks/yalidine-webhook.e2e.test.ts
@@ -27,6 +27,9 @@ import type { AppDb } from "@/db";
vi.mock("@/db", () => ({ getDb: vi.fn(() => realDb) }));
vi.mock("@/workflows/capi-helpers", () => ({
shouldTriggerCapiPurchase: vi.fn(() => false),
+ shouldTriggerCapiConfirmed: vi.fn(() => false),
+ getCapiWorkflowId: vi.fn((id: string, stage: string, event: string) => `capi-${id}-${stage}-${event}`),
+ resolveConversionForStage: vi.fn(() => ({ shouldFire: false })),
resolveCapiDispatch: vi.fn(() => ({ send: false, reason: "tracking-disabled", message: "mock skip" })),
}));
diff --git a/cod-server/src/lib/capi-log.ts b/cod-server/src/lib/capi-log.ts
index 09bd1cd..929bc7e 100644
--- a/cod-server/src/lib/capi-log.ts
+++ b/cod-server/src/lib/capi-log.ts
@@ -1,11 +1,12 @@
import { capiEventLog } from "@/db/schema";
import type { getDb } from "@/db";
-export type CapiLogStatus = "sent" | "failed" | "skipped";
+export type CapiLogStatus = "sent" | "failed" | "skipped" | "claimed";
export interface CapiLogEntry {
orderId: string;
eventName: "Lead" | "Purchase";
+ stage?: "checkout" | "confirmed" | "delivered";
status: CapiLogStatus;
metaEventId?: string | null;
error?: string | null;
@@ -21,6 +22,7 @@ export async function logCapiEvent(db: ReturnType, entry: CapiLogE
id: crypto.randomUUID(),
orderId: entry.orderId,
eventName: entry.eventName,
+ stage: entry.stage ?? "delivered",
status: entry.status,
metaEventId: entry.metaEventId ?? null,
error: entry.error ?? null,
diff --git a/cod-server/src/openapi/schemas/store.ts b/cod-server/src/openapi/schemas/store.ts
index 0c43ac3..c3cec52 100644
--- a/cod-server/src/openapi/schemas/store.ts
+++ b/cod-server/src/openapi/schemas/store.ts
@@ -70,9 +70,9 @@ export const StorePixelConfigSchema = z
testEventCode: z.string().nullable().openapi({
description: "Meta test event code — used only while Test Mode is on. Set to null in production.",
}),
- conversionEvent: z.enum(["Lead", "Purchase"]).openapi({
+ conversionEvent: z.enum(["Purchase", "Purchase_Confirmed", "Purchase_Delivered", "Lead"]).openapi({
description:
- "Merchant-chosen conversion event: 'Lead' fires at order placement (deduplicated with the browser pixel), 'Purchase' fires at confirmed delivery.",
+ "Merchant-chosen conversion event: 'Purchase' fires immediately at checkout, 'Purchase_Confirmed' fires on order confirmation, 'Purchase_Delivered' fires on confirmed delivery, and 'Lead' fires at checkout.",
}),
testMode: z.boolean().openapi({
description: "When true, Conversions API events carry test_event_code to Meta's test stream.",
diff --git a/cod-server/src/test-utils/cloudflare-workers.ts b/cod-server/src/test-utils/cloudflare-workers.ts
index 29145e7..9244c47 100644
--- a/cod-server/src/test-utils/cloudflare-workers.ts
+++ b/cod-server/src/test-utils/cloudflare-workers.ts
@@ -1,8 +1,27 @@
/**
* Test stub for the `cloudflare:workers` runtime module, which does not exist
* under Node. Vitest aliases `cloudflare:workers` to this file so packages
- * that extend `WorkerEntrypoint` (e.g. @cloudflare/workers-oauth-provider)
- * can be imported in unit tests. Only symbols actually referenced by those
- * packages are implemented.
+ * that extend `WorkerEntrypoint` or `WorkflowEntrypoint` can be imported in unit tests.
*/
export class WorkerEntrypoint {}
+
+export class WorkflowEntrypoint {
+ env: Env;
+ ctx: any;
+ constructor(ctx: any, env: Env) {
+ this.ctx = ctx;
+ this.env = env;
+ }
+}
+
+export interface WorkflowEvent {
+ payload: T;
+ instanceId?: string;
+ timestamp?: Date;
+}
+
+export interface WorkflowStep {
+ do(name: string, callbackOrConfig: any, callback?: () => Promise): Promise;
+ sleep(name: string, duration: string | number): Promise;
+ sleepUntil(name: string, timestamp: Date | number): Promise;
+}
diff --git a/cod-server/src/test-utils/cloudflare-workflows.ts b/cod-server/src/test-utils/cloudflare-workflows.ts
new file mode 100644
index 0000000..d85d094
--- /dev/null
+++ b/cod-server/src/test-utils/cloudflare-workflows.ts
@@ -0,0 +1,9 @@
+/**
+ * Test stub for the `cloudflare:workflows` runtime module under Node.
+ */
+export class NonRetryableError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = "NonRetryableError";
+ }
+}
diff --git a/cod-server/src/workflows/capi-helpers.test.ts b/cod-server/src/workflows/capi-helpers.test.ts
index 7246452..30f19c3 100644
--- a/cod-server/src/workflows/capi-helpers.test.ts
+++ b/cod-server/src/workflows/capi-helpers.test.ts
@@ -1,5 +1,10 @@
import { describe, it, expect } from "vitest";
-import { shouldTriggerCapiPurchase, resolveCapiDispatch, type CapiDispatchConfig } from "./capi-helpers";
+import {
+ shouldTriggerCapiPurchase,
+ shouldTriggerCapiConfirmed,
+ resolveCapiDispatch,
+ type CapiDispatchConfig,
+} from "./capi-helpers";
function config(overrides: Partial = {}): CapiDispatchConfig {
return {
@@ -12,6 +17,19 @@ function config(overrides: Partial = {}): CapiDispatchConfig
};
}
+describe("shouldTriggerCapiConfirmed", () => {
+ it("triggers on confirmed", () => {
+ expect(shouldTriggerCapiConfirmed("confirmed")).toBe(true);
+ });
+
+ it("does not trigger on other statuses", () => {
+ expect(shouldTriggerCapiConfirmed("pending")).toBe(false);
+ expect(shouldTriggerCapiConfirmed("delivered")).toBe(false);
+ expect(shouldTriggerCapiConfirmed("out_for_delivery")).toBe(false);
+ expect(shouldTriggerCapiConfirmed("cancelled")).toBe(false);
+ });
+});
+
describe("shouldTriggerCapiPurchase", () => {
it("triggers on delivered", () => {
expect(shouldTriggerCapiPurchase("delivered", 16)).toBe(true);
@@ -27,6 +45,7 @@ describe("shouldTriggerCapiPurchase", () => {
it("never triggers on other statuses", () => {
expect(shouldTriggerCapiPurchase("dispatched", 1)).toBe(false);
expect(shouldTriggerCapiPurchase("returned", 16)).toBe(false);
+ expect(shouldTriggerCapiPurchase("confirmed", 1)).toBe(false);
});
});
@@ -77,4 +96,42 @@ describe("resolveCapiDispatch", () => {
testEventCode: null,
});
});
+
+ describe("stage-aware dispatch", () => {
+ it("handles Purchase_Confirmed mode at confirmed stage", () => {
+ const cfg = config({ conversionEvent: "Purchase_Confirmed" });
+ expect(resolveCapiDispatch(cfg, "Purchase", "confirmed")).toEqual({
+ send: true,
+ testEventCode: null,
+ });
+ expect(resolveCapiDispatch(cfg, "Purchase", "checkout")).toMatchObject({
+ send: false,
+ reason: "conversion-event-mismatch",
+ });
+ });
+
+ it("handles Purchase_Delivered mode at delivered stage", () => {
+ const cfg = config({ conversionEvent: "Purchase_Delivered" });
+ expect(resolveCapiDispatch(cfg, "Purchase", "delivered")).toEqual({
+ send: true,
+ testEventCode: null,
+ });
+ expect(resolveCapiDispatch(cfg, "Purchase", "checkout")).toMatchObject({
+ send: false,
+ reason: "conversion-event-mismatch",
+ });
+ });
+
+ it("handles Lead mode at checkout stage", () => {
+ const cfg = config({ conversionEvent: "Lead" });
+ expect(resolveCapiDispatch(cfg, "Lead", "checkout")).toEqual({
+ send: true,
+ testEventCode: null,
+ });
+ expect(resolveCapiDispatch(cfg, "Purchase", "checkout")).toMatchObject({
+ send: false,
+ reason: "conversion-event-mismatch",
+ });
+ });
+ });
});
diff --git a/cod-server/src/workflows/capi-helpers.ts b/cod-server/src/workflows/capi-helpers.ts
index 5ea93a7..93557ed 100644
--- a/cod-server/src/workflows/capi-helpers.ts
+++ b/cod-server/src/workflows/capi-helpers.ts
@@ -1,3 +1,14 @@
+import {
+ ConversionStage,
+ MetaEventName,
+ ConversionMode,
+ ConversionDecision,
+ resolveConversionForStage,
+ getCapiWorkflowId,
+} from "./conversion-model";
+
+export * from "./conversion-model";
+
// Southern wilayas with 5-10 day delivery — Workflow triggers at out_for_delivery
// for these to stay within Meta's 7-day window.
const LONG_HAUL_WILAYA_IDS = new Set([
@@ -13,6 +24,10 @@ const LONG_HAUL_WILAYA_IDS = new Set([
* Determines whether to trigger CodCapiWorkflow for a given status transition.
* Call this after updateOrderStatus() resolves.
*/
+export function shouldTriggerCapiConfirmed(newStatus: string): boolean {
+ return newStatus === "confirmed";
+}
+
export function shouldTriggerCapiPurchase(
newStatus: string,
wilayaId: number | null | undefined,
@@ -25,7 +40,7 @@ export function shouldTriggerCapiPurchase(
export interface CapiDispatchConfig {
enabled: boolean;
accessToken: string;
- conversionEvent: "Lead" | "Purchase";
+ conversionEvent: ConversionMode;
testMode: boolean;
testEventCode: string | null;
}
@@ -42,12 +57,12 @@ export type CapiDispatch =
/**
* Single gate for every CAPI send: the store's tracking must be enabled, carry
* an access token, and the merchant must have chosen `eventName` as the
- * conversion event. Test Mode routes events to Meta's test stream — the
- * test_event_code is attached only when it is on.
+ * conversion event for the given business stage.
*/
export function resolveCapiDispatch(
config: CapiDispatchConfig | null | undefined,
- eventName: "Lead" | "Purchase",
+ eventName: MetaEventName,
+ stage?: ConversionStage,
): CapiDispatch {
if (!config?.enabled) {
return { send: false, reason: "tracking-disabled", message: "Tracking disabled in store settings" };
@@ -59,12 +74,32 @@ export function resolveCapiDispatch(
message: "No CAPI access token — configure it in Settings → Tracking",
};
}
- if (config.conversionEvent !== eventName) {
- return {
- send: false,
- reason: "conversion-event-mismatch",
- message: `Conversion event is set to ${config.conversionEvent} — ${eventName} not sent`,
- };
+
+ if (stage) {
+ const decision = resolveConversionForStage(config.conversionEvent, stage);
+ if (!decision.shouldFire || decision.eventName !== eventName) {
+ return {
+ send: false,
+ reason: "conversion-event-mismatch",
+ message: decision.reason ?? `Conversion event is set to ${config.conversionEvent} — ${eventName} not sent at stage ${stage}`,
+ };
+ }
+ } else {
+ const allowed =
+ (eventName === "Purchase" &&
+ (config.conversionEvent === "Purchase" ||
+ config.conversionEvent === "Purchase_Confirmed" ||
+ config.conversionEvent === "Purchase_Delivered")) ||
+ (eventName === "Lead" && config.conversionEvent === "Lead");
+
+ if (!allowed) {
+ return {
+ send: false,
+ reason: "conversion-event-mismatch",
+ message: `Conversion event is set to ${config.conversionEvent} — ${eventName} not sent`,
+ };
+ }
}
+
return { send: true, testEventCode: config.testMode ? config.testEventCode : null };
}
diff --git a/cod-server/src/workflows/capi.test.ts b/cod-server/src/workflows/capi.test.ts
new file mode 100644
index 0000000..5f41e64
--- /dev/null
+++ b/cod-server/src/workflows/capi.test.ts
@@ -0,0 +1,122 @@
+import { describe, it, expect } from "vitest";
+import {
+ CodCapiParamsSchema,
+ shouldTriggerCapiPurchase,
+ shouldTriggerCapiConfirmed,
+ getCapiWorkflowId,
+} from "./capi";
+
+describe("CodCapiWorkflow — Runtime Payload Validation (Zod)", () => {
+ it("validates a standard purchase payload with default stage", () => {
+ const payload = {
+ orderId: "ord_123",
+ eventName: "Purchase",
+ triggeredAt: 1716000000,
+ triggerStatus: "delivered",
+ };
+
+ const parsed = CodCapiParamsSchema.safeParse(payload);
+ expect(parsed.success).toBe(true);
+ if (parsed.success) {
+ expect(parsed.data.stage).toBe("delivered");
+ expect(parsed.data.eventName).toBe("Purchase");
+ }
+ });
+
+ it("validates an explicit confirmed stage payload", () => {
+ const payload = {
+ orderId: "ord_456",
+ eventName: "Purchase",
+ stage: "confirmed",
+ triggeredAt: 1716000000,
+ triggerStatus: "confirmed",
+ eventSourceUrl: "https://example.com/thank-you",
+ };
+
+ const parsed = CodCapiParamsSchema.safeParse(payload);
+ expect(parsed.success).toBe(true);
+ if (parsed.success) {
+ expect(parsed.data.stage).toBe("confirmed");
+ expect(parsed.data.eventSourceUrl).toBe("https://example.com/thank-you");
+ }
+ });
+
+ it("validates a checkout lead payload", () => {
+ const payload = {
+ orderId: "ord_789",
+ eventName: "Lead",
+ stage: "checkout",
+ triggeredAt: 1716000000,
+ triggerStatus: "order_created",
+ };
+
+ const parsed = CodCapiParamsSchema.safeParse(payload);
+ expect(parsed.success).toBe(true);
+ });
+
+ it("rejects empty orderId", () => {
+ const payload = {
+ orderId: "",
+ eventName: "Purchase",
+ triggeredAt: 1716000000,
+ triggerStatus: "delivered",
+ };
+
+ const parsed = CodCapiParamsSchema.safeParse(payload);
+ expect(parsed.success).toBe(false);
+ });
+
+ it("rejects unmapped event names", () => {
+ const payload = {
+ orderId: "ord_123",
+ eventName: "InitiateCheckout",
+ triggeredAt: 1716000000,
+ triggerStatus: "checkout",
+ };
+
+ const parsed = CodCapiParamsSchema.safeParse(payload);
+ expect(parsed.success).toBe(false);
+ });
+
+ it("rejects negative or invalid timestamps", () => {
+ const payload = {
+ orderId: "ord_123",
+ eventName: "Purchase",
+ triggeredAt: -1,
+ triggerStatus: "delivered",
+ };
+
+ const parsed = CodCapiParamsSchema.safeParse(payload);
+ expect(parsed.success).toBe(false);
+ });
+
+ it("rejects malformed eventSourceUrl", () => {
+ const payload = {
+ orderId: "ord_123",
+ eventName: "Purchase",
+ triggeredAt: 1716000000,
+ triggerStatus: "delivered",
+ eventSourceUrl: "not-a-valid-url",
+ };
+
+ const parsed = CodCapiParamsSchema.safeParse(payload);
+ expect(parsed.success).toBe(false);
+ });
+});
+
+describe("CodCapiWorkflow — Module Re-exports", () => {
+ it("re-exports shouldTriggerCapiPurchase", () => {
+ expect(typeof shouldTriggerCapiPurchase).toBe("function");
+ expect(shouldTriggerCapiPurchase("delivered", 16)).toBe(true);
+ });
+
+ it("re-exports shouldTriggerCapiConfirmed", () => {
+ expect(typeof shouldTriggerCapiConfirmed).toBe("function");
+ expect(shouldTriggerCapiConfirmed("confirmed")).toBe(true);
+ });
+
+ it("re-exports getCapiWorkflowId", () => {
+ expect(typeof getCapiWorkflowId).toBe("function");
+ expect(getCapiWorkflowId("ord_1", "delivered", "Purchase")).toBe("capi-ord_1-delivered-Purchase");
+ });
+});
diff --git a/cod-server/src/workflows/capi.ts b/cod-server/src/workflows/capi.ts
index 7cd2910..9049a14 100644
--- a/cod-server/src/workflows/capi.ts
+++ b/cod-server/src/workflows/capi.ts
@@ -1,44 +1,51 @@
/**
- * CodCapiWorkflow — durable Cloudflare Workflow that sends a Meta CAPI Purchase event
- * when an order reaches a delivery-trigger status.
+ * CodCapiWorkflow — durable Cloudflare Workflow that sends Meta CAPI events
+ * across the COD lifecycle (Checkout, Phone Confirmation, Delivery).
*
- * Decoupled from the status handler: CAPI failure can never block delivery confirmation.
- * Instance ID: `capi-{orderId}-Purchase` — deterministic, prevents duplicate Workflows.
+ * Decoupled from the status handler: CAPI failure can never block order or delivery confirmation.
+ * Instance ID: `capi-{orderId}-{stage}-{eventName}` — deterministic, prevents duplicate Workflows.
*
* Steps:
- * 1. fetch-order-and-config — load fresh order + pixel config + product ids from D1
- * 2. log-skip — audit row when the send could never happen
- * (no access token, or event_time outside Meta's 7-day window)
- * 3. send-capi-event — POST to Meta; network/5xx throw so the Workflow
- * retries (5 attempts, exponential backoff). 4xx returns
- * success:false — Meta rejected the batch, retrying won't help.
- * 4. log-result / log-failure — audit row in capi_event_log for every outcome
- *
- * Skips silently when tracking is disabled or the merchant chose Lead as the
- * conversion event — normal operation, not an anomaly worth an audit row.
+ * 0. validate-payload — Zod schema validation (NonRetryableError on malformed inputs)
+ * 1. fetch-order-and-config — load fresh order + pixel config + verify stage eligibility
+ * 2. log-skip — audit row when the send could never happen (no token / expired)
+ * 3. claim-event — atomic D1 insert with unique key constraint on (order_id, stage, event_name)
+ * 4. send-capi-event — POST to Meta; network/5xx throw to trigger exponential retry
+ * 5. log-result / log-failure — updates the claimed row with final outcome
*/
import { WorkflowEntrypoint, type WorkflowEvent, type WorkflowStep } from "cloudflare:workers";
import { NonRetryableError } from "cloudflare:workflows";
+import { z } from "zod";
import type { Env } from "@/types/env";
import { getDb } from "@/db";
-import { orders, communes, orderProducts, stores } from "@/db/schema";
-import { eq } from "drizzle-orm";
+import { orders, communes, orderProducts, stores, capiEventLog } from "@/db/schema";
+import { eq, and, sql } from "drizzle-orm";
import { getPixelConfig } from "../../../cod-shared/queries/pixel-config";
import { sendCapiEvent, type CapiResult } from "@/lib/capi";
-import { resolveCapiDispatch } from "./capi-helpers";
+import {
+ resolveCapiDispatch,
+ resolveConversionForStage,
+ getCapiWorkflowId,
+ shouldTriggerCapiPurchase,
+ shouldTriggerCapiConfirmed,
+ type ConversionStage,
+ type MetaEventName,
+} from "./capi-helpers";
import { logCapiEvent } from "@/lib/capi-log";
const SEVEN_DAYS_SECONDS = 7 * 24 * 3600;
-export type CodCapiParams = {
- orderId: string;
- eventName: "Lead" | "Purchase";
- triggeredAt: number; // Unix timestamp — becomes event_time
- triggerStatus: string;
- /** Verified-domain page URL — required by Meta for website events. */
- eventSourceUrl?: string;
-};
+export const CodCapiParamsSchema = z.object({
+ orderId: z.string().min(1, "orderId is required"),
+ eventName: z.enum(["Lead", "Purchase"]),
+ stage: z.enum(["checkout", "confirmed", "delivered"]).default("delivered"),
+ triggeredAt: z.number().int().positive("triggeredAt must be a positive integer"),
+ triggerStatus: z.string().min(1, "triggerStatus is required"),
+ eventSourceUrl: z.string().url().optional(),
+});
+
+export type CodCapiParams = z.infer;
function splitName(customerName: string): { firstName?: string; lastName?: string } {
const parts = customerName.trim().split(/\s+/);
@@ -50,7 +57,14 @@ function splitName(customerName: string): { firstName?: string; lastName?: strin
export class CodCapiWorkflow extends WorkflowEntrypoint {
async run(event: WorkflowEvent, step: WorkflowStep) {
- const { orderId, eventName, triggeredAt, eventSourceUrl } = event.payload;
+ // Step 0 — Runtime schema validation
+ const parsed = CodCapiParamsSchema.safeParse(event.payload);
+ if (!parsed.success) {
+ const errorMsg = parsed.error.issues.map((e) => `${e.path.join(".")}: ${e.message}`).join(", ");
+ throw new NonRetryableError(`Invalid CAPI workflow payload: ${errorMsg}`);
+ }
+
+ const { orderId, eventName, stage, triggeredAt, eventSourceUrl, triggerStatus } = parsed.data;
// Step 1 — fetch fresh data from D1 (never rely on stale params)
const data = await step.do("fetch-order-and-config", async () => {
@@ -81,11 +95,15 @@ export class CodCapiWorkflow extends WorkflowEntrypoint {
// Resolve commune name and postal code for user_data
const communeRow = order.communeId
- ? await db.select({ name: communes.name, postalCode: communes.postalCode }).from(communes).where(eq(communes.id, order.communeId)).get()
+ ? await db
+ .select({ name: communes.name, postalCode: communes.postalCode })
+ .from(communes)
+ .where(eq(communes.id, order.communeId))
+ .get()
: null;
// Single-tenant: one store per database
- const storeRow = await db.select({ id: stores.id }).from(stores).limit(1).get();
+ const storeRow = await db.select({ id: stores.id, domain: stores.domain }).from(stores).limit(1).get();
if (!storeRow) throw new NonRetryableError("No store found");
const productRows = await db
@@ -95,29 +113,40 @@ export class CodCapiWorkflow extends WorkflowEntrypoint {
const pixelConfig = await getPixelConfig(db, storeRow.id);
+ // Shared conversion model resolver check
+ const decision = resolveConversionForStage(pixelConfig?.conversionEvent, stage);
+
return {
order,
+ storeDomain: storeRow.domain,
cityName: communeRow?.name ?? null,
postalCode: communeRow?.postalCode ?? null,
contentIds: [...new Set(productRows.map((r) => r.productId))],
pixelConfig,
+ shouldFire: decision.shouldFire && decision.eventName === eventName,
+ skipReason: decision.reason,
};
});
+ if (!data.shouldFire) {
+ return { skipped: true, reason: data.skipReason ?? "stage_mismatch" };
+ }
+
// Step 2 — gate: merchant must have chosen this event, with a token, tracking on
- const dispatch = resolveCapiDispatch(data.pixelConfig, eventName);
+ const dispatch = resolveCapiDispatch(data.pixelConfig, eventName, stage);
if (!dispatch.send) {
if (dispatch.reason === "no-access-token") {
await step.do("log-skip", async () => {
await logCapiEvent(getDb(this.env.DB), {
orderId,
eventName,
+ stage,
status: "skipped",
error: dispatch.message,
});
});
}
- return;
+ return { skipped: true, reason: dispatch.reason };
}
// Step 3 — guard: 7-day Meta hard limit on event_time
@@ -127,17 +156,73 @@ export class CodCapiWorkflow extends WorkflowEntrypoint {
await logCapiEvent(getDb(this.env.DB), {
orderId,
eventName,
+ stage,
status: "skipped",
error: `event_time expired: order ${orderId} is ${Math.round(ageSeconds / 3600)}h old — outside Meta 7-day window`,
});
});
- return;
+ return { skipped: true, reason: "event_time_expired" };
+ }
+
+ // Step 4 — Atomic Event Claim in D1
+ const claim = await step.do("claim-event", async () => {
+ const db = getDb(this.env.DB);
+ const now = new Date().toISOString();
+ const claimId = `claim-${orderId}-${stage}-${eventName}`;
+
+ try {
+ const res = await db.run(
+ sql`INSERT INTO capi_event_log (id, order_id, event_name, stage, status, sent_at)
+ VALUES (${claimId}, ${orderId}, ${eventName}, ${stage}, 'claimed', ${now})
+ ON CONFLICT (order_id, stage, event_name) DO NOTHING`
+ );
+
+ if (res.meta.changes === 1) {
+ return { claimed: true };
+ }
+ } catch {
+ // Fallback check if driver does not support changes property or syntax
+ }
+
+ // Check existing row
+ const existing = await db
+ .select({ status: capiEventLog.status, sentAt: capiEventLog.sentAt })
+ .from(capiEventLog)
+ .where(
+ and(
+ eq(capiEventLog.orderId, orderId),
+ eq(capiEventLog.stage, stage),
+ eq(capiEventLog.eventName, eventName)
+ )
+ )
+ .get();
+
+ if (existing) {
+ if (existing.status === "sent") {
+ return { claimed: false, reason: "already_sent" };
+ }
+ if (existing.status === "claimed") {
+ const elapsed = Date.now() - new Date(existing.sentAt).getTime();
+ if (elapsed < 10 * 60 * 1000) {
+ return { claimed: false, reason: "already_in_flight" };
+ }
+ }
+ }
+
+ return { claimed: true };
+ });
+
+ if (!claim.claimed) {
+ return { skipped: true, reason: claim.reason };
}
- // Step 4 — send CAPI event. Network errors and Meta 5xx throw, which
- // engages the Workflow's retry (5 attempts, 30s exponential backoff).
+ // Step 5 — Send CAPI Event to Meta
+ const finalEventSourceUrl =
+ eventSourceUrl ?? (data.storeDomain ? `https://${data.storeDomain}/thank-you` : undefined);
+
const { firstName, lastName } = splitName(data.order.customerName);
let capiResult: CapiResult;
+
try {
capiResult = await step.do(
"send-capi-event",
@@ -147,7 +232,7 @@ export class CodCapiWorkflow extends WorkflowEntrypoint {
eventName,
eventId: orderId,
eventTime: triggeredAt,
- eventSourceUrl,
+ eventSourceUrl: finalEventSourceUrl,
userData: {
phone: data.order.phone,
firstName,
@@ -169,27 +254,35 @@ export class CodCapiWorkflow extends WorkflowEntrypoint {
);
} catch (err) {
await step.do("log-failure", async () => {
- await logCapiEvent(getDb(this.env.DB), {
- orderId,
- eventName,
- status: "failed",
- error: err instanceof Error ? err.message : String(err),
- });
+ const db = getDb(this.env.DB);
+ const now = new Date().toISOString();
+ const errorMsg = err instanceof Error ? err.message : String(err);
+ await db.run(
+ sql`UPDATE capi_event_log
+ SET status = 'failed', error = ${errorMsg}, sent_at = ${now}
+ WHERE order_id = ${orderId} AND stage = ${stage} AND event_name = ${eventName}`
+ );
});
throw err;
}
- // Step 5 — write audit row to capi_event_log
+ // Step 6 — Update claim row in capi_event_log
await step.do("log-result", async () => {
- await logCapiEvent(getDb(this.env.DB), {
- orderId,
- eventName,
- status: capiResult.success ? "sent" : "failed",
- metaEventId: capiResult.fbtrace_id ?? null,
- error: capiResult.error ?? null,
- });
+ const db = getDb(this.env.DB);
+ const now = new Date().toISOString();
+ const status = capiResult.success ? "sent" : "failed";
+ const metaEventId = capiResult.fbtrace_id ?? null;
+ const error = capiResult.error ?? null;
+
+ await db.run(
+ sql`UPDATE capi_event_log
+ SET status = ${status}, meta_event_id = ${metaEventId}, error = ${error}, sent_at = ${now}
+ WHERE order_id = ${orderId} AND stage = ${stage} AND event_name = ${eventName}`
+ );
});
+
+ return { success: capiResult.success, metaEventId: capiResult.fbtrace_id };
}
}
-export { shouldTriggerCapiPurchase } from "./capi-helpers";
+export { shouldTriggerCapiPurchase, shouldTriggerCapiConfirmed, getCapiWorkflowId } from "./capi-helpers";
diff --git a/cod-server/src/workflows/conversion-model.test.ts b/cod-server/src/workflows/conversion-model.test.ts
new file mode 100644
index 0000000..e1bd9bc
--- /dev/null
+++ b/cod-server/src/workflows/conversion-model.test.ts
@@ -0,0 +1,141 @@
+import { describe, it, expect } from "vitest";
+import {
+ resolveConversionForStage,
+ getCapiWorkflowId,
+ type ConversionMode,
+ type ConversionStage,
+} from "./conversion-model";
+
+describe("Conversion Model — resolveConversionForStage", () => {
+ describe("Mode: Purchase (Instant Checkout)", () => {
+ const mode: ConversionMode = "Purchase";
+
+ it("fires Purchase at checkout", () => {
+ const res = resolveConversionForStage(mode, "checkout");
+ expect(res).toEqual({
+ shouldFire: true,
+ eventName: "Purchase",
+ stage: "checkout",
+ });
+ });
+
+ it("skips at confirmed stage", () => {
+ const res = resolveConversionForStage(mode, "confirmed");
+ expect(res.shouldFire).toBe(false);
+ expect(res.reason).toContain("Purchase");
+ });
+
+ it("skips at delivered stage", () => {
+ const res = resolveConversionForStage(mode, "delivered");
+ expect(res.shouldFire).toBe(false);
+ expect(res.reason).toContain("Purchase");
+ });
+ });
+
+ describe("Mode: Purchase_Confirmed (Phone Confirmation)", () => {
+ const mode: ConversionMode = "Purchase_Confirmed";
+
+ it("skips at checkout stage", () => {
+ const res = resolveConversionForStage(mode, "checkout");
+ expect(res.shouldFire).toBe(false);
+ expect(res.reason).toContain("Purchase_Confirmed");
+ });
+
+ it("fires Purchase at confirmed stage", () => {
+ const res = resolveConversionForStage(mode, "confirmed");
+ expect(res).toEqual({
+ shouldFire: true,
+ eventName: "Purchase",
+ stage: "confirmed",
+ });
+ });
+
+ it("skips at delivered stage", () => {
+ const res = resolveConversionForStage(mode, "delivered");
+ expect(res.shouldFire).toBe(false);
+ expect(res.reason).toContain("Purchase_Confirmed");
+ });
+ });
+
+ describe("Mode: Purchase_Delivered (Carrier Delivery)", () => {
+ const mode: ConversionMode = "Purchase_Delivered";
+
+ it("skips at checkout stage", () => {
+ const res = resolveConversionForStage(mode, "checkout");
+ expect(res.shouldFire).toBe(false);
+ expect(res.reason).toContain("Purchase_Delivered");
+ });
+
+ it("skips at confirmed stage", () => {
+ const res = resolveConversionForStage(mode, "confirmed");
+ expect(res.shouldFire).toBe(false);
+ expect(res.reason).toContain("Purchase_Delivered");
+ });
+
+ it("fires Purchase at delivered stage", () => {
+ const res = resolveConversionForStage(mode, "delivered");
+ expect(res).toEqual({
+ shouldFire: true,
+ eventName: "Purchase",
+ stage: "delivered",
+ });
+ });
+ });
+
+ describe("Mode: Lead (Checkout Lead)", () => {
+ const mode: ConversionMode = "Lead";
+
+ it("fires Lead at checkout", () => {
+ const res = resolveConversionForStage(mode, "checkout");
+ expect(res).toEqual({
+ shouldFire: true,
+ eventName: "Lead",
+ stage: "checkout",
+ });
+ });
+
+ it("skips at confirmed stage", () => {
+ const res = resolveConversionForStage(mode, "confirmed");
+ expect(res.shouldFire).toBe(false);
+ expect(res.reason).toContain("Lead");
+ });
+
+ it("skips at delivered stage", () => {
+ const res = resolveConversionForStage(mode, "delivered");
+ expect(res.shouldFire).toBe(false);
+ expect(res.reason).toContain("Lead");
+ });
+ });
+
+ describe("Null / Undefined mode defaults to Purchase", () => {
+ it("defaults to Purchase at checkout", () => {
+ expect(resolveConversionForStage(null, "checkout")).toEqual({
+ shouldFire: true,
+ eventName: "Purchase",
+ stage: "checkout",
+ });
+ expect(resolveConversionForStage(undefined, "checkout")).toEqual({
+ shouldFire: true,
+ eventName: "Purchase",
+ stage: "checkout",
+ });
+ });
+ });
+});
+
+describe("Conversion Model — getCapiWorkflowId", () => {
+ it("generates deterministic workflow IDs per business event", () => {
+ expect(getCapiWorkflowId("ord-123", "checkout", "Purchase")).toBe(
+ "capi-ord-123-checkout-Purchase"
+ );
+ expect(getCapiWorkflowId("ord-123", "checkout", "Lead")).toBe(
+ "capi-ord-123-checkout-Lead"
+ );
+ expect(getCapiWorkflowId("ord-123", "confirmed", "Purchase")).toBe(
+ "capi-ord-123-confirmed-Purchase"
+ );
+ expect(getCapiWorkflowId("ord-123", "delivered", "Purchase")).toBe(
+ "capi-ord-123-delivered-Purchase"
+ );
+ });
+});
diff --git a/cod-server/src/workflows/conversion-model.ts b/cod-server/src/workflows/conversion-model.ts
new file mode 100644
index 0000000..985ded7
--- /dev/null
+++ b/cod-server/src/workflows/conversion-model.ts
@@ -0,0 +1,90 @@
+/**
+ * Shared Meta Ads Conversion Model for CodFlow.
+ *
+ * Establishes one centralized business contract across:
+ * - Storefront checkout (Pixel + CAPI)
+ * - Merchant call-center phone confirmation (dashboard status update)
+ * - Courier logistics delivery (Yalidine / ZR Express webhooks)
+ * - CAPI Workflow execution, validation, and idempotency claims
+ *
+ * Grounded in .agents/skills/meta-ads/SKILL.md and official Meta documentation:
+ * - capi-deduplication.md (eventID === event_id, matching event_name)
+ * - pixel-reference-standard-events.md (Purchase at checkout, Lead at sign-up)
+ * - capi-offline-events.md (delayed COD delivery conversion)
+ */
+
+export type ConversionStage = "checkout" | "confirmed" | "delivered";
+export type MetaEventName = "Lead" | "Purchase";
+export type ConversionMode = "Purchase" | "Purchase_Confirmed" | "Purchase_Delivered" | "Lead";
+
+export interface ConversionDecision {
+ shouldFire: boolean;
+ eventName?: MetaEventName;
+ stage?: ConversionStage;
+ reason?: string;
+}
+
+/**
+ * Maps a merchant's chosen tracking configuration to the active business stage.
+ *
+ * | Configuration | Business stage | Meta event |
+ * |----------------------|----------------------|------------|
+ * | Lead | Checkout | Lead |
+ * | Purchase | Checkout | Purchase |
+ * | Purchase_Confirmed | Phone confirmation | Purchase |
+ * | Purchase_Delivered | Delivery/payment | Purchase |
+ */
+export function resolveConversionForStage(
+ mode: ConversionMode | null | undefined,
+ stage: ConversionStage
+): ConversionDecision {
+ const resolvedMode: ConversionMode = mode ?? "Purchase";
+
+ switch (stage) {
+ case "checkout":
+ if (resolvedMode === "Purchase") {
+ return { shouldFire: true, eventName: "Purchase", stage: "checkout" };
+ }
+ if (resolvedMode === "Lead") {
+ return { shouldFire: true, eventName: "Lead", stage: "checkout" };
+ }
+ return {
+ shouldFire: false,
+ reason: `Conversion mode is '${resolvedMode}' — checkout stage does not fire a conversion event.`,
+ };
+
+ case "confirmed":
+ if (resolvedMode === "Purchase_Confirmed") {
+ return { shouldFire: true, eventName: "Purchase", stage: "confirmed" };
+ }
+ return {
+ shouldFire: false,
+ reason: `Conversion mode is '${resolvedMode}' — phone confirmation stage does not fire a conversion event.`,
+ };
+
+ case "delivered":
+ if (resolvedMode === "Purchase_Delivered") {
+ return { shouldFire: true, eventName: "Purchase", stage: "delivered" };
+ }
+ return {
+ shouldFire: false,
+ reason: `Conversion mode is '${resolvedMode}' — delivery stage does not fire a conversion event.`,
+ };
+
+ default:
+ return { shouldFire: false, reason: `Unknown stage: ${stage}` };
+ }
+}
+
+/**
+ * Centralized deterministic Workflow ID constructor.
+ * Guarantees that any trigger source (dashboard, webhook, or checkout) targeting the
+ * same business conversion event produces the exact same Cloudflare Workflow instance ID.
+ */
+export function getCapiWorkflowId(
+ orderId: string,
+ stage: ConversionStage,
+ eventName: MetaEventName
+): string {
+ return `capi-${orderId}-${stage}-${eventName}`;
+}
diff --git a/cod-server/vitest.config.ts b/cod-server/vitest.config.ts
index 7eff1aa..309fb99 100644
--- a/cod-server/vitest.config.ts
+++ b/cod-server/vitest.config.ts
@@ -47,6 +47,7 @@ export default defineConfig({
// Stub the Workers runtime module so @cloudflare/workers-oauth-provider
// can load under Node in unit tests (see src/test-utils/cloudflare-workers.ts).
'cloudflare:workers': resolve(__dirname, './src/test-utils/cloudflare-workers.ts'),
+ 'cloudflare:workflows': resolve(__dirname, './src/test-utils/cloudflare-workflows.ts'),
}
},
diff --git a/cod-shared/db/schema.ts b/cod-shared/db/schema.ts
index b484607..7167e4d 100644
--- a/cod-shared/db/schema.ts
+++ b/cod-shared/db/schema.ts
@@ -1206,7 +1206,7 @@ export const storePixelConfig = sqliteTable("store_pixel_config", {
/** Meta test event code — used during integration testing only. Set to null in production. */
testEventCode: text("test_event_code"),
/** Which CAPI event the merchant optimizes for — chosen explicitly in the dashboard, never defaulted by the UI. */
- conversionEvent: text("conversion_event", { enum: ["Lead", "Purchase"] }).notNull().default("Purchase"),
+ conversionEvent: text("conversion_event", { enum: ["Lead", "Purchase", "Purchase_Confirmed", "Purchase_Delivered"] }).notNull().default("Purchase"),
/** When true, CAPI events carry test_event_code to Meta's test stream instead of production measurement. */
testMode: integer("test_mode", { mode: "boolean" }).notNull().default(false),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
@@ -1263,17 +1263,25 @@ export const storeEmailConfig = sqliteTable("store_email_config", {
* status: 'sent' | 'failed' | 'skipped'
* metaEventId: fbtrace_id from Meta response (present on success only).
*/
-export const capiEventLog = sqliteTable("capi_event_log", {
- id: text("id").primaryKey(),
- orderId: text("order_id")
- .notNull()
- .references(() => orders.id),
- eventName: text("event_name").notNull(),
- status: text("status").notNull(),
- metaEventId: text("meta_event_id"),
- error: text("error"),
- sentAt: text("sent_at").notNull(),
-});
+export const capiEventLog = sqliteTable(
+ "capi_event_log",
+ {
+ id: text("id").primaryKey(),
+ orderId: text("order_id")
+ .notNull()
+ .references(() => orders.id),
+ eventName: text("event_name").notNull(),
+ stage: text("stage").notNull().default("delivered"),
+ status: text("status").notNull(),
+ metaEventId: text("meta_event_id"),
+ error: text("error"),
+ sentAt: text("sent_at").notNull(),
+ },
+ (t) => ({
+ orderIdx: index("idx_capi_event_log_order").on(t.orderId),
+ claimUnique: uniqueIndex("idx_capi_event_log_claim").on(t.orderId, t.stage, t.eventName),
+ })
+);
// ─── better-auth tables ──────────────────────────────────────────────────────
// Declared so the dashboard's auth code can reference them via Drizzle. The D1
diff --git a/cod-shared/queries/pixel-config.ts b/cod-shared/queries/pixel-config.ts
index 2d6fec1..a519724 100644
--- a/cod-shared/queries/pixel-config.ts
+++ b/cod-shared/queries/pixel-config.ts
@@ -2,13 +2,20 @@ import type { AppDb } from "../db/client";
import { storePixelConfig } from "../db/schema";
import { eq } from "drizzle-orm";
-export type ConversionEvent = "Lead" | "Purchase";
+export type ConversionEvent = "Purchase" | "Purchase_Confirmed" | "Purchase_Delivered" | "Lead";
-export async function getPixelConfig(db: AppDb, storeId: string) {
+export async function getPixelConfig(db: AppDb, storeId?: string) {
+ if (storeId) {
+ return db
+ .select()
+ .from(storePixelConfig)
+ .where(eq(storePixelConfig.storeId, storeId))
+ .get();
+ }
return db
.select()
.from(storePixelConfig)
- .where(eq(storePixelConfig.storeId, storeId))
+ .limit(1)
.get();
}
diff --git a/cod-shared/queries/store.ts b/cod-shared/queries/store.ts
index 3b4649c..5d3b191 100644
--- a/cod-shared/queries/store.ts
+++ b/cod-shared/queries/store.ts
@@ -69,7 +69,11 @@ export async function getStoreConfig(db: AppDb, storeId: string) {
if (!store) return null;
const [pixelRow, otpRow] = await Promise.all([
db
- .select({ pixelId: storePixelConfig.pixelId, enabled: storePixelConfig.enabled })
+ .select({
+ pixelId: storePixelConfig.pixelId,
+ enabled: storePixelConfig.enabled,
+ conversionEvent: storePixelConfig.conversionEvent,
+ })
.from(storePixelConfig)
.where(eq(storePixelConfig.storeId, storeId))
.get(),
@@ -82,6 +86,7 @@ export async function getStoreConfig(db: AppDb, storeId: string) {
return {
...store,
pixelId: pixelRow?.enabled ? pixelRow.pixelId : null,
+ conversionEvent: pixelRow?.enabled ? (pixelRow.conversionEvent as "Purchase" | "Purchase_Confirmed" | "Purchase_Delivered" | "Lead") : "Purchase",
otpEnabled: otpRow?.enabled === true,
};
}