Skip to main content

Ad Planning — Data Flow & Architecture

Reference for how data flows through the ad-planning module: from event ingestion to score calculation to segment membership.

Last updated: 2026-04-10


Architecture Overview

┌─────────────────────────────────────────────────────────────────┐
│ Event Sources │
├──────────┬───────────┬────────────┬─────────────┬──────────────┤
│ Storefront│ Meta Ads │ Feedback │ Lead Forms │ order.placed │
│ Analytics │ Insights │ Module │ │ │
└────┬─────┴─────┬─────┴─────┬──────┴──────┬──────┴──────┬───────┘
│ │ │ │ │
▼ ▼ ▼ ▼ ▼
┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ ┌──────────────┐
│Analytics│ │Insights│ │Sentiment│ │Lead │ │Purchase │
│Event │ │Sync │ │Analysis │ │Convert │ │Conversion │
│Tracking│ │Job │ │Workflow │ │Workflow│ │Workflow │
└───┬────┘ └───┬────┘ └───┬────┘ └───┬────┘ └──────┬───────┘
│ │ │ │ │
▼ ▼ ▼ ▼ ▼
┌───────────────────────────────────────────────────────────────┐
│ AD-PLANNING MODULE │
│ │
│ ┌────────────┐ ┌──────────────┐ ┌──────────────────────┐ │
│ │ Conversion │ │ Campaign │ │ CustomerScore │ │
│ │ (purchase, │ │ Attribution │ │ (CLV, engagement, │ │
│ │ lead, etc)│ │ (UTM→camp) │ │ churn, NPS) │ │
│ └──────┬─────┘ └──────┬───────┘ └──────────┬───────────┘ │
│ │ │ │ │
│ ┌──────▼───────────────▼──────────────────────▼───────────┐ │
│ │ Segment Evaluation Engine │ │
│ │ Enriched customer data → criteria rules → membership │ │
│ └─────────────────────────┬───────────────────────────────┘ │
│ │ │
│ ┌─────────────────────────▼──────────────────────────────┐ │
│ │ CustomerSegment → SegmentMember → Dashboard/API │ │
│ └────────────────────────────────────────────────────────┘ │
└───────────────────────────────────────────────────────────────┘

Purchase Conversion Flow

When an order is placed, the trackPurchaseConversionWorkflow runs 8 steps:

order.placed event


1. fetchOrderStep ─── query.graph("order") for computed total


2. resolvePersonStep ─── match order email → Person record


3. findAttributionStep ─── session → person's visitor_ids → CampaignAttribution


4. createPurchaseConversion ─── write Conversion record (conversion_value = order.total)


5. recalculateCLVStep ─── full CLV prediction from purchase history


6. addPurchaseJourneyStep ─── add CustomerJourney "purchase" event


7. recalculateEngagement ─── activity-weighted engagement score


8. recalculateChurnRisk ─── weighted risk (activity + purchase + engagement + sentiment)


9. rebuildAutoSegments ─── rebuild all active auto-update segments

Attribution Scoping

The findAttributionStep resolves attribution for a purchase in this order:

  1. Session-based: If session_id is provided, look up CampaignAttribution by analytics_session_id
  2. Person-based (last-touch): Look up this person's historical visitor_ids from their Conversion records, then find the most recent resolved CampaignAttribution for those visitors
  3. Unattributed: Fall through with platform: "generic", attribution_method: "unattributed"
caution

Previously step 2 fetched ALL system-wide attributions and assigned the most recent one — leaking attribution across customers. Fixed in April 2026.


Segment Evaluation

When a segment is built (via API rebuild or the weekly rebuild-segments-job), the buildSegmentWorkflow runs:

Enriched Customer Data Object

Each person is enriched with data from multiple sources:

FieldSourceDescription
total_ordersMedusa Order module (via customer email match)Lifetime order count
total_spentAd-planning Conversion records (purchase type)Sum of conversion_value for paid purchases
avg_order_valueComputed: total_spent / paidPurchases.lengthExcludes €0 draft orders
days_since_last_orderLatest purchase conversion converted_atDays elapsed
total_purchasesAd-planning Conversion count (purchase type)May differ from total_orders if not all orders have conversions
total_conversionsAll Conversion count for this personIncludes non-purchase types
nps_scoreCustomerScore (type: nps)-100 to 100
engagement_scoreCustomerScore (type: engagement)0 to 100
clv / clv_scoreCustomerScore (type: clv)Monetary CLV prediction
churn_riskCustomerScore (type: churn_risk)0 to 100 (higher = more at-risk)
agePerson date_of_birthComputed at evaluation time
country / city / statePersonAddressFirst address for this person
customer_since_daysMedusa Customer created_atDays since account creation
has_accountMedusa CustomerBoolean
tagsPerson tagsArray of tag names

Criteria Evaluation

Rules support these operators:

OperatorDescriptionNumeric Coercion
>=, <=, >, <Numeric comparison✅ String values coerced via Number()
==, !=Equality (loose)No
contains, not_containsString substring matchNo
in, not_inArray membershipNo
betweenRange (inclusive)
within_last_daysDate within N daysDate parsing
older_than_daysDate older than N daysDate parsing

Logic groups: AND (all rules), OR (any rule), NOT (none match)


Score Calculations

CLV (Customer Lifetime Value)

averageOrderValue = totalRevenue / purchaseCount
monthlyFrequency = purchaseCount / lifespanMonths
predictedCLV = averageOrderValue × monthlyFrequency × adjustedLifespan
remainingCLV = max(0, predictedCLV - totalRevenue)

Lifespan adjustments:

  • Default: 24 months
  • High frequency (avg < 90 days between purchases): 36 months
  • Single purchase: 12 months, frequency set to 1/3 per month
  • Low frequency (avg > 180 days): 6 months

Tiers: platinum (≥50k), gold (≥20k), silver (≥5k), bronze

Engagement Score

Activity-weighted with time decay:

ActivityBase Weight
Purchase25 + log10(value+1) × 2 bonus
Lead form submission15
Feedback10
Page engagement5
Add to cart8
Begin checkout12
Other3

Time decay: 1.0 - (daysAgo / 365) clamped to [0.1, 1.0] Normalized: min(100, round(totalScore / 5))

Churn Risk

Weighted components (sum to 1.0):

FactorWeightFormula
Activity inactivity0.35min(1, daysSinceActivity / 90)
Purchase inactivity0.30min(1, daysSincePurchase / 180)
Engagement decline0.20min(1, max(0, engagementDecline / 100))
Negative sentiment0.15min(1, negativeWeight / recentSentiments.length)

Negative sentiment weights: "very_negative" = 1.5, "negative" = 1.0

NPS (Net Promoter Score)

Standard NPS: ((promoters - detractors) / total) × 100

5-point scale classification (from raw rating):

  • 5 → promoter
  • 4 → passive
  • 1-3 → detractor

10-point scale (standard):

  • 9-10 → promoter
  • 7-8 → passive
  • 0-6 → detractor

Currency Handling

DataCurrency Source
conversion.conversion_valueorder.currency_code (from Medusa order)
conversion.currencyNullable — set from order or null for non-purchase conversions
Meta Ads campaign.spendAd account currency (typically INR for Indian accounts)
Dashboard ROIRevenue (store currency) vs spend (ad account currency) — UI converts via exchange rate

The admin UI's useCurrencyFormatter("INR") hook fetches a live exchange rate from the Frankfurter API (ECB data, cached 1 hour) and converts ad spend to the store's default currency before displaying.


Scheduled Jobs

JobScheduleWhat it does
recalculate-customer-scores0 3 * * 0 (Sunday 3 AM)Recalculates engagement, CLV, churn risk for customers with activity in last 30 days
resolve-attributions0 2 * * * (Daily 2 AM)Bulk-resolves unattributed sessions from the last 7 days (up to 5000 per run)
rebuild-segments0 4 * * 1 (Monday 4 AM)Rebuilds all active auto-update segments

Key API Endpoints

MethodEndpointPurpose
GET/admin/ad-planning/dashboardDashboard overview with KPIs, trends, campaign ROI
GET/admin/ad-planning/conversions/statsAggregated conversion statistics with time series
GET/admin/ad-planning/attribution/statsAttribution resolution stats
GET/admin/ad-planning/experimentsList A/B experiments (filterable by status, experiment_type)
GET/admin/ad-planning/experiments/:id/resultsStatistical results for an experiment
GET/admin/ad-planning/scoresCustomer scores with person name, percentile, tier
GET/admin/ad-planning/segments/:idSegment detail with member count
POST/admin/ad-planning/segments/:idRebuild segment with { rebuild: true }
GET/admin/ad-planning/journeys/:personIdCustomer journey timeline
GET/admin/ad-planning/journeys/funnelFunnel analysis
GET/admin/exchange-rate?from=INR&to=EURLive exchange rate (Frankfurter/ECB)