Skip to content

Landing experiments: attribution capture + purchase reconciler (spec §7b) #143

Description

@kilbot

Landing experiments: attribution capture + purchase reconciler (spec §7b)

Part of the WCPOS landing-experiments program. This is the wcpos.com plan (plan 3 of 4) from the program charter.

  • Program spec: docs/superpowers/specs/2026-06-12-landing-experiments-design.md §7b (and §5.3 cross-domain attribution, §6.4 month-0 sequencing) in wcpos/wp-admin-landing.
  • Companion PR: feat: experiment-ready landing — bootstrap runtime, indie & free-plus variants wp-admin-landing#20 — the landing page emits outbound links to wcpos.com carrying ref=wp-admin, utm_*, lv={variant}, aid={distinct_id}, pv={plugin_version} (opaque IDs only; aid is anon_id or site_uuid depending on consent state).
  • In-progress plugin work: wcpos/woocommerce-pos branch feature/anon-id-identity adds wcpos_anon_id; the licence-activation request will carry wcpos_anon_id + site_uuid (the reconciler's strongest join key).
  • No server changes were attempted as part of filing this issue. Owner: Paul.

⚠️ Stack reconciliation — read first

The spec text (written at the charter level) says "WooCommerce checkout" / "Woo order meta" for wcpos.com. That is not this repo's stack. Verified from the codebase:

  • This site is Next.js 16 (App Router, next-intl, middleware-based cookie handling), deployed on Vercel.
  • Commerce runs on Medusa; licensing runs on Keygen CE (see CONTEXT.md). Order completion happens at POST /api/store/cart/complete via completeCart() in src/services/core/external/medusa-client.tsthere is no WooCommerce and no WP order-meta here.
  • So "Woo order-meta copy at checkout" is implemented as Medusa order metadata written at cart completion. This plan is grounded in the actual stack; the spec's Woo phrasing is treated as charter-level shorthand.

This repo already has substantial attribution plumbing that month-0 work should extend rather than rebuild:

Existing asset File Relevance
Server-set distinct-id cookie wcpos-distinct-id (1yr, sameSite=lax, set in middleware) src/middleware.ts, src/lib/analytics/distinct-id.ts The wcpos_attr cookie should follow this exact server-set pattern
GDPR consent cookie wcpos-analytics-consent + gating src/lib/analytics/consent.ts All capture/cookie/bucketing here is consent-gated and fails closed. Attribution must respect this.
PostHog server service: resolveProCheckoutVariant, trackServerEvent (POSTs to ${host}/capture/) src/services/core/analytics/posthog-service.ts The reconciler reuses trackServerEvent/the capture pattern; flag eval already hits ${host}/flags?v=2
Existing checkout server event checkout_completed with order_id, customer_id, distinct_id, experiment, variant src/app/api/store/cart/complete/route.ts The order-meta write and the reconciler hook in here
PostHog config (host/key/serverKey) src/lib/analytics/config.ts, src/utils/env.ts (NEXT_PUBLIC_POSTHOG_HOST, POSTHOG_API_KEY) Reconciler capture credentials already wired

PostHog is self-hosted at ph.wcpos.com (per program memory; env var NEXT_PUBLIC_POSTHOG_HOST). Join the same project the landing page uses.

GDPR note / open decision for Paul. The spec asks for an unconditional 90-day wcpos_attr cookie on arrival and server-side capture. This repo currently sets the distinct-id cookie and captures only after wcpos-analytics-consent === 'granted' (CNIL/GDPR posture). The recommended reconciliation: treat inbound aid/utm/lv as opaque, non-PII campaign params carried in a strictly-functional first-party cookie, but keep PostHog capture consent-gated exactly as today. Flag this tension explicitly in implementation; do not silently weaken the existing consent gate.


Month-0 scope (ships month 0 per §6.4)

1. PostHog pageview + UTM/aid capture on wcpos.com (arrival baselines)

So tier-2 experiments have arrival baselines before they need them.

  • Capture a pageview + the inbound utm_* and aid query params on wcpos.com landings (especially /, /pro, /pro/checkout).
  • Reuse the existing PostHog client wiring; register aid, utm_source/medium/campaign/term/content, lv, pv, ref as event/super properties on first arrival.
  • Consent-gated to match posthog-service.ts / middleware behaviour (no capture before granted).

Acceptance: A wp-admin → wcpos.com click with ?ref=wp-admin&aid=…&utm_*=…&lv=indie&pv=1.9.3 produces a PostHog pageview carrying those params (post-consent). Params are URL-decoded and length-capped; unknown params ignored.
Test: Unit test param extraction/normalisation; e2e (Playwright, already in repo) asserting the capture call fires with the expected props after consent granted, and does not fire pre-consent.

2. Server-set 90-day wcpos_attr first-party cookie on arrival

Carries aid / utm_* / lv so attribution survives the gap between arrival and a later (possibly multi-visit) checkout. Server-set, not document.cookie — Safari ITP caps JS-written cookie lifetimes to ~7 days; a server Set-Cookie is not subject to that cap.

  • Set-cookie point in this stack: src/middleware.ts — alongside the existing withDistinctIdCookie(request, response) helper. Add a sibling withAttributionCookie() that, when the request URL carries aid/utm_*/lv (and they are not already captured), sets wcpos_attr via response.cookies.set(...). Cookie options mirror getDistinctIdCookieOptions() but maxAge = 60*60*24*90, sameSite: 'lax', secure in prod, httpOnly: true (server-only; nothing client-side needs to read it — this is stricter than the distinct-id cookie and avoids ITP entirely).
  • Value: compact JSON (or URL-encoded kv) of { aid, utm_*, lv, pv, ref, first_seen_at }. First-write-wins for the campaign attributes (don't overwrite an existing attribution on a later organic visit); refresh only first_seen_at is not done — the point is to preserve the original touch.
  • Respect consent: only set when consent is granted (or carry the documented "functional campaign param" exception above, per Paul's decision).

Acceptance: First arrival with attribution params yields a Set-Cookie: wcpos_attr=…; Max-Age=7776000; HttpOnly; SameSite=Lax. A second arrival with different params does not clobber the first. Cookie absent when no attribution params and none previously set.
Test: middleware.test.ts (exists) cases: params present → cookie set with 90d/HttpOnly; cookie already present → not overwritten; no params → no cookie; consent denied → no cookie (or functional-exception path, whichever Paul picks).

3. Checkout: copy aid/UTM/lv from the cookie into order metadata

At order creation, persist the attribution so the reconciler can join on order_id.

  • Hook in src/app/api/store/cart/complete/route.ts (the existing completion path). Read wcpos_attr from cookies(), parse it, and write aid/utm_*/lv/pv/ref into Medusa order metadata via the cart/order metadata API in medusa-client.ts (the Medusa equivalent of "order meta").
  • Also fold the same fields into the existing checkout_completed server event (it already emits order_id, distinct_id, variant).
  • Idempotent: writing metadata for an already-completed order is a no-op.

Acceptance: A completed order whose session had wcpos_attr has metadata.aid, metadata.utm_*, metadata.lv set and matching the cookie. Missing cookie → metadata fields absent/null, completion still succeeds (attribution is best-effort, never blocks checkout).
Test: Extend route.test.ts (exists): cookie present → metadata write asserted; cookie absent → completion still 200, no metadata write, no throw; malformed cookie → fails closed, logged, checkout unaffected.


The reconciler (lives here — the only system that sees orders, refunds, and licence activations)

A scheduled job that joins three sources and writes attribution truth to PostHog. It lives in wcpos.com because this is the only system with visibility into Medusa orders/refunds and Keygen licence activations.

Join sources

  1. Medusa order metadataaid/utm_*/lv written at checkout (above), keyed by order_id. Refund/credit state from Medusa.
  2. Keygen licence activations — once the plugin PR ships, each activation carries wcpos_anon_id + site_uuid (via src/services/core/external/license-client.ts / keygen). This is the strongest exposure join: it maps a paying site back to the aid the landing page emitted.
  3. wcpos_attr cookie value — last-resort join for the same browser session when order metadata is thin.

Behaviour

  • Scheduled job. Implement as a Vercel Cron-invoked authenticated route (e.g. src/app/api/cron/reconcile-attribution/route.ts, protected by a CRON_SECRET bearer check — Vercel Cron pattern). Runs e.g. hourly/daily; processes orders since the last watermark.
  • Idempotent per order_id. Maintain a processed-order watermark / dedup key so re-runs never double-emit. pro_purchase_attributed fires at most once per order_id.
  • Emits to PostHog via the existing capture pattern (trackServerEvent / ${host}/capture/):
    • pro_purchase_attributed — once per order_id; props: order_id, aid, utm_*, lv, pv, site_uuid (when activation-joined), amount, sku, is_lifetime, join_source: order_meta|activation|cookie.
    • pro_purchase_refunded — on a refund/revocation; carries order_id + original attribution; drives refund-netting.
    • pro_purchase_unattributed — order_id with no resolvable aid after all three joins (feeds the coverage metric's denominator gap).
  • rekeyed_site flag — set on attributions whose id (anon_id/site_uuid) has no exposure history in PostHog (e.g. a site that rotated its anon_id, or activated without ever hitting the instrumented landing page). Emitted as a property so these are analysable separately and don't silently inflate/deflate attribution.
  • Refund-netting — attributed purchase counts are net of refunds over the reporting window; the reconciler is the source of truth for the refund-adjusted posterior the program reports (beta-binomial, directional — never "significant", per §5.3).
  • Attribution-coverage metricattributed / (attributed + unattributed) per month; target ≥80% monthly (this is the §6.5 "Attribution Health" dashboard input). Emit a periodic attribution_coverage event/metric or expose via HogQL.

Acceptance criteria

  • Re-running the job over the same window emits zero duplicate pro_purchase_attributed events (idempotency proven by watermark + dedup test).
  • An order joined via activation gets join_source: activation and a populated site_uuid; an order joined only via cookie gets join_source: cookie.
  • A refunded order emits pro_purchase_refunded and is excluded from the net attributed count.
  • An order with no resolvable id emits pro_purchase_unattributed and counts only in the coverage denominator.
  • An attribution whose id has no prior exposure carries rekeyed_site: true.
  • Monthly coverage is computed and surfaced; alert/flag when < 80%.

Test notes

  • Unit: join resolution precedence (activation > order_meta > cookie), idempotency/watermark, refund-netting math, coverage calc, rekeyed_site detection. Mock Medusa + Keygen + PostHog capture (the repo already mocks posthog-service in route.test.ts).
  • Integration: seeded fixture of orders/refunds/activations → assert the exact event set emitted (count, props), then re-run → assert no new events.
  • Cron auth: route rejects requests without the CRON_SECRET bearer.

Cross-links

Sequencing (§6.4)

wcpos.com arrival capture (#1), wcpos_attr cookie (#2), and checkout order-meta (#3) ship month 0. The reconciler ships month 0 too, but its activation join only becomes fully effective once the plugin's feature/anon-id-identity PR lands and activations begin carrying wcpos_anon_id + site_uuid; until then it joins via order metadata + cookie and reports the coverage gap honestly.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions