Challenges and streaks

Gamification adds two member-facing mechanics on top of the ledger: challenges (merchant-defined goals with a fixed point reward) and streaks (an automatic consecutive-week visit counter, with optional milestone bonuses). Both are read live from canonical_transactions — nothing here is a counter that can drift; progress is recomputed from the same purchase history the ledger itself is built from, every time it’s shown.

Challenge types

A challenge (POST /v1/challenges, scoped to a program) is one of three types. Progress is a single aggregate query over the member’s transactions inside the challenge’s evaluation window (below):

type Dashboard label How progress is measured
visit_count Visit count Distinct calendar days with a purchase inside the window (COUNT(DISTINCT occurred_at::date)) — two sales on the same day count once.
spend_total Spend total Net spend inside the window, summed in the program currency’s minor units (COALESCE(SUM(net), 0)) — refunds lower it.
distinct_weeks Distinct weeks Distinct ISO weeks with at least one purchase inside the window (COUNT(DISTINCT date_trunc('week', occurred_at))).

target, window_days, and reward_points are all positive integers — enforced both by the request schema and by database CHECK constraints, so a malformed challenge can’t be stored even by a direct API call.

The evaluation window

Every challenge has a rolling window_days-day look-back, but two things clamp it:

window = [ max(starts_at, now − window_days), min(now, ends_at ?? now) ]

This is the same window a merchant’s dashboard view (GET /v1/members/:id/challenges) and a member’s own view (GET /v1/me/challenges, see the member portal guide) both call — one formula, so the two surfaces can never disagree about a member’s progress.

Lifecycle

A challenge always starts draft (status isn’t caller-settable at creation) and moves through a linear lifecycle — there’s no paused state the way promotions have one:

draft → active → ended

Rewards land on the nightly run, not the sale

This is the one piece of timing worth being explicit about: completing a challenge does not pay out immediately. The platform never writes a reward synchronously while a sale is being processed — a slow or failing reward write must never be able to hold up checkout. Instead, a nightly sweep looks for members whose computed progress has reached target and pays them then.

That sweep is one of three independent legs dispatched from the jobs worker’s 30 1 * * * UTC cron slot (alongside the existing nightly RFM-scoring and wallet-offer-expiry sweeps — each leg has its own try/catch, so a failure in one never blocks the others). Concretely: a member’s purchase can push their progress bar to 100% at any time of day, but the completion — the payout, the ledger entry, and the “Completed” badge in the portal — only appears after that night’s run. The member portal’s Challenges page says this plainly once a bar is full but not yet awarded: “Target reached — your bonus lands after the nightly award run.”

The sweep is idempotent: running it twice awards nothing the second time. Idempotency is belt-and-braces — a UNIQUE(tenant_id, challenge_id, member_id) row in challenge_completions is the primary gate (one completion per member per challenge, non-repeating in v1 — completing a challenge once is the only award it will ever pay a given member, even if it stayed active and their progress reset and refilled later), and the paired ledger entry is written with a deterministic external_id (challenge-<challengeId>-<memberId>) that the ledger’s own uniqueness constraint would refuse a second time regardless. The reward itself is an ADJUST entry (not EARN — there’s no earning rule behind a challenge bonus), and it also writes a timeline event so it shows up in the member’s activity history on the merchant side.

Streaks

Unlike challenges, a streak needs no setup — every member has one, computed the same way: the consecutive chain of ISO weeks (Monday-anchored, evaluated in UTC) in which the member made at least one purchase.

Milestones (optional)

A program can optionally configure streak milestones — {weeks, reward_points} pairs, edited on the program’s settings page in the dashboard (validated: positive integers, no duplicate weeks values, since a member can only ever be awarded once per distinct weeks value). With no milestones configured, members still see their current/best streak; no bonus is ever paid. Where milestones exist, the same nightly sweep awards each one the first time a member’s current streak reaches it, using the ledger’s own uniqueness (external_id: streak-<memberId>-<weeks>w) as the idempotency guard directly — there’s no separate completions table for streaks the way there is for challenges, because that key is already naturally non-repeating.

Where members see this

Everything above is merchant/API-side. Members see their live progress, streak, and completion history in the member portal — see the member portal guide for the Home and Challenges pages, and how a member gets there in the first place.

API surface (merchant-facing)

All of these require the same permission family as promotions and campaigns — campaigns:read for the GETs, campaigns:manage for everything that writes:

In the dashboard, all of this lives under Engagement → Challenges (list, create/edit, and the lifecycle actions), with the streak-milestones editor on each program’s settings page. Full request/ response shapes are in the API reference.

Next steps