Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cod-astro/theme01/src/core/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
16 changes: 13 additions & 3 deletions cod-astro/theme01/src/pages/thank-you.astro
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -108,16 +117,17 @@ const steps = [
</div>
</StoreLayout>

{pixelId && (
{pixelId && checkoutEvent && (
<script type="text/javascript" set:html={`
(function() {
if (typeof fbq !== 'function') return;
var eventId = ${JSON.stringify(orderId)};
// No orderId means no server Lead mirror to deduplicate against — firing
// No orderId means no server mirror to deduplicate against — firing
// with a different id would double-count the conversion. Skip instead.
if (!eventId) return;
var total = ${JSON.stringify(totalNum)};
fbq('track', 'Lead', total ? { value: total, currency: 'DZD' } : {}, { eventID: eventId });
var eventName = ${JSON.stringify(checkoutEvent)};
fbq('track', eventName, total ? { value: total, currency: 'DZD' } : {}, { eventID: eventId });
})();
`} />
)}
1 change: 1 addition & 0 deletions cod-astro/theme01/src/theme/config/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export const DEFAULT_CONFIG: StoreConfig = {
otpEnabled: false,
status: "active",
pixelId: null,
conversionEvent: null,
};

export interface StoreContext {
Expand Down
10 changes: 7 additions & 3 deletions cod-client-astro/locales/ar/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "الوضع التجريبي مفعّل — الأحداث لا تُحتسب في القياس الفعلي. عطّله قبل الانطلاق.",
Expand Down
12 changes: 8 additions & 4 deletions cod-client-astro/locales/en/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
10 changes: 7 additions & 3 deletions cod-client-astro/locales/fr/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
4 changes: 2 additions & 2 deletions cod-client-astro/src/features/settings/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}
Expand Down
5 changes: 5 additions & 0 deletions cod-server/src/db/migrations/0022_capi_event_claim.sql
Original file line number Diff line number Diff line change
@@ -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`);
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions cod-server/src/endpoints/orders/orders.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
3 changes: 3 additions & 0 deletions cod-server/src/endpoints/orders/routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => ({
Expand Down
12 changes: 9 additions & 3 deletions cod-server/src/endpoints/orders/status-transitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -78,18 +78,24 @@ export async function updateStatus(c: Context<AppContext>) {
// 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,
},
Expand Down
11 changes: 6 additions & 5 deletions cod-server/src/endpoints/store/capi-lead-trigger.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand All @@ -111,9 +111,10 @@ describe("CAPI Lead workflow trigger", () => {
{ id: string; params: Record<string, unknown> }
];
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);
Expand Down
78 changes: 59 additions & 19 deletions cod-server/src/endpoints/store/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AppContext>) {
const storeId = c.get("storeId")!;
Expand Down Expand Up @@ -203,26 +207,62 @@ export async function createStoreOrder(c: Context<AppContext>) {
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");
}
Expand Down
Loading