Paid Memberships
The platform records the membership. Your POS — or however you take payment today — charges the member. We never touch the money.
A paid membership here is an entitlement, not a transaction: you sell a “Gold membership” or
“VIP club” your own way (at the till, over the phone, however you already collect payment), then
tell the platform it happened. From that point the platform holds the entitlement — a paid tier,
monthly bonus points, member-only rewards and challenges — through activation, renewal,
cancellation, and lapse, honestly and idempotently. There is no checkout anywhere in this feature,
no card capture, no platform invoice. membership_plans.billing_provider is hard-locked to
'merchant' — see The billing seam for what that reserves for
later without any rework today.
Plans
A membership plan (membership_plans, migration 0030) belongs to one program and defines what
a membership costs and grants:
| Field | Notes |
|---|---|
name / description |
Unique per tenant by name. |
price_minor / currency |
Display only. Money in minor units; currency must be exactly 3 uppercase letters (ISO 4217) — stricter than most other currency fields in this API, because nothing else validates it before it’s shown to a member. Nothing here ever charges a card. |
period |
monthly or yearly. |
perks_json |
{tier_id?, monthly_points?, member_only_access?} — see Perks below. |
billing_provider |
Always 'merchant' in v1 — server-forced, not even accepted on the request body. |
status |
draft → active → archived, same shape as rewards/challenges. Only a draft plan can be edited (PATCH); once active, live subscribers may already depend on its price/perks, so editing is locked — archive and create a new plan instead. |
Plans are created and activated through POST /v1/membership-plans and its :id/activate /
:id/archive actions (campaigns:manage — the exact same permission family promotions and
challenges already use).
Perks
Every perk rides existing machinery — there is no separate “membership ledger” or “membership tier system.”
Paid tier
A plan can name a tier_id from the program’s own tier ladder. While a member’s subscription is
live — active, past_due but still inside its grace window, or canceled but not yet past
its paid-through date — that tier is applied as an override on top of the ordinary
points/spend-computed tier.
The override only ever elevates: it wins over the computed tier when its rank is strictly
higher than the computed tier’s, and a computed “no tier” always loses to any real override tier.
It never demotes a member whose organically-earned standing already outranks the plan’s tier. This
is wired directly into the existing tier engine (assignTier’s subscriptionTierId option,
packages/tiers) — not a parallel system — so a member’s tier badge, tier history, and timeline
all read exactly the way they always have, just with one more input feeding the decision.
Activation and renewal apply the override atomically, in the same transaction as the ledger/status write, so “the tier held” is true the instant the API responds — never a “maybe crossed a threshold” side effect you’d have to wait on. Nothing changes the member’s tier at cancel-click (see Lifecycle below); the override keeps applying through the paid-for period, and the nightly sweep is the only thing that ever resumes the computed tier, once the subscription actually expires.
Monthly points
A plan can grant monthly_points — an ordinary ADJUST ledger entry fired on activation and on
every renewal. It’s idempotent per period: the grant’s external_id is shaped
sub-<subscriptionId>-<periodStartDate>, so retrying the same activation/renewal, or a concurrent
race that resolves to a replay, never grants twice for the same period. A plan with no
monthly_points configured simply grants nothing — that’s an honest no-op, not an error.
Member-only access
A plan can flag member_only_access: true. A member has access whenever they hold at least one
live subscription (same live scope as the paid tier above) to a plan carrying that flag.
Enforcement is server-side, in two places, never just in a UI:
- Rewards — every redemption-quote entry point (merchant, till, and the consumer app) runs a
pre-quote gate before the ledger is ever touched. A
member_onlyreward quoted by a member without access fails honestly with422 MEMBER_ONLY_REWARD; a subscriber quotes it exactly like any other reward. - Challenges — the nightly reward sweep skips awarding a
member_onlychallenge’s completion to anyone who isn’t currently a subscriber, using the identical access check.
The portal and till both show member-only rewards/challenges locked, not hidden — a “Members only” badge stays on the item, and tapping a locked one reveals an honest “Join the membership to unlock — ask at the counter.” rather than making it disappear. That UI choice is cosmetic; the actual gate above is what makes it safe regardless of what any client renders.
Lifecycle
activate period ends, unrenewed grace ends (period_end + 7d)
──────────────────▶ active ───────────────────────▶ past_due ───────────────────▶ expired
│ ▲ │
│ └──────────── renew ──────────────┘ (any time — early renewal
│ extends further; renewing
│ from past_due clears grace)
│
cancel
│
▼ period ends, no grace
canceled ─────────────────────────────────────────────────────▶ expired
-
Activate —
POST /v1/members/:id/subscriptions {plan_id, billing_ref?}(till, dashboard, or API key;members:write) is the call you make after you’ve already taken payment your own way. It opens the period[now, now + 1 period]— calendar-month-aware, clamped to the target month’s last day (a plan anchored on Jan 31 renews to Feb 28, or Feb 29 in a leap year, never rolling into March) — fires the period’s grants, and applies the tier override. A member who already holds a live subscription to the same plan gets an honest409 SUBSCRIPTION_ALREADY_LIVE; a member can hold live subscriptions to more than one different plan at once. -
Renew —
POST /v1/subscriptions/:id/renew {billing_ref?}advances the period from the subscription’s currentperiod_end— never from “now” — so there’s no drift from renewing a few days early or a few days late. Early renewal is allowed and simply extends further from the still-future end date; renewing apast_duesubscription clears the grace and returns it toactive. Renewal is idempotent: retrying the exact same renewal (a lost response, a double-tapped button, a network timeout after the server already committed) replays the stored result —replayed: true, period unchanged, nothing granted a second time — instead of advancing and granting twice. -
Cancel —
POST /v1/subscriptions/:id/cancelsetsstatus: 'canceled'immediately, but the member’s perks — including the paid tier and member-only access — run out honestly at the period they already paid for, not at the moment of the cancel click. Nothing about the member’s tier changes at cancel time; there’s nothing to reassign until the period actually ends. There is no grace period for a cancellation the way there is for a missed renewal. -
The nightly lapse sweep — a cron leg (
30 1 * * *, UTC), run once per tenant, with three ordered transitions in a single pass (a tenant that missed several nights cascades straight through in one run rather than waiting extra days):activepast itsperiod_end→past_due, withgrace_until = period_end + 7 days.past_duepast itsgrace_until→expired.canceledpast itsperiod_end→expired(no grace, by design).
Only on the transition to
expired— never on the move intopast_due— does the sweep resume the member’s computed tier basis (a no-op if their own earned tier is already at least as high as the paid one was). This is the fifth isolated leg riding the existing30 1 * * *slot, alongside nightly RFM scoring, wallet-offer expiry, the gamification reward sweep, and the optimizer leg — still 13 cron triggers total, not 14 (seeinfra/DEPLOY.md’s crons step). Each leg has its own try/catch, so a failure in one never blocks the others in the same run.
Activating and renewing
At the till
The member screen’s Membership action checks the member’s real subscription state first (not
just what happened earlier in this same till session — it reads the same GET /v1/subscriptions
the dashboard uses). A member with no live subscription sees an Activate form (a plan picker,
auto-selected when there’s only one active plan); a member who already holds one sees their
status and current period, and, if past_due, “Renew by <date> to keep this membership.” —
with a Renew button.
In the dashboard
Engagement → Memberships has a Plans tab (create/edit while draft, with perk pickers — a
tier select drawn from the program’s real tier ladder, a monthly-points number field, a
member-only-access toggle — then Activate/Archive) and a Subscribers tab (every subscription,
status/period/grace at a glance, manual Activate/Renew/Cancel with confirmation dialogs). A
member’s own record page carries a Membership card showing their full subscription history, not
just whichever one is currently live.
The frozen-retry renewal contract
Renewing — at the till and in the dashboard alike — uses the same “frozen attempt” pattern the
till already uses for sales and redemptions: the first tap mints one
{Idempotency-Key, subscriptionId, body} tuple and freezes it. If that call fails, the button
relabels itself “Retry” and resends the exact same frozen tuple — never a freshly minted key
— so a retry always lands on the server’s idempotent-replay path instead of risking a second
advance-and-grant for an ambiguous failure (a timeout after the server already committed, for
example). Only a genuinely new renewal — a fresh click after a prior one already succeeded —
mints a new key.
The member’s view (portal)
The member portal’s Membership tab has two states, both driven honestly by what the API actually knows:
- Not a member yet — a public, pre-login-capable catalog (
GET /v1/portal/plans) renders each active plan as a pitch card: name, price/period (e.g. “$9.99/mo”), and its perks in plain English — “+250 points every period,” “Includes the Gold tier” (the tier’s real name, never a bare id), “Unlocks member-only rewards & challenges” — under one honest line: “Ask at the counter to join — memberships are charged by the shop.” There is no checkout button anywhere on this page. - Already a member — a status card built from
GET /v1/me/membership(the member’s own session).activereads “Active through <date>.”;past_duereads “Renew at the counter by <grace date>.”;canceledreads “Runs out <date>.” — no auto-renew language anywhere, because v1 has none. A member with no live subscription — whether they never joined, or their last one already lapsed — sees the same join pitch either way; the API deliberately doesn’t distinguish those two cases for a member reading their own status.
Member-only rewards and challenges elsewhere in the portal follow the same locked-not-hidden rule described in Member-only access above.
Targeting members by subscription status
Campaigns’ segment DSL exposes a subscription_status field (eq/in only) with five
values, computed fresh from member_subscriptions on every read — never from a status column that
might not have caught up with the nightly sweep yet:
| Value | Means |
|---|---|
none |
No subscription rows at all — this member has never held a paid membership. |
active |
Holds at least one subscription whose status is literally active right now. |
past_due |
Holds one that’s past_due and still inside its grace window — the honest “renew now” target. Once grace has actually elapsed, a member reads expired instead, even before the nightly sweep gets around to writing that status — a “renew before grace ends” send must never go out stale. |
canceled |
Holds one that’s canceled but not yet past its paid-through period — still entitled, but deliberately kept separate from active here: a renewal-reminder campaign must never treat someone who already canceled as if they’re on track to renew. |
expired |
Every subscription row this member has ever held has run its course. A win-back target. |
This is a different grouping from the “live” entitlement scope perks use (which merges active/grace-past_due/still-in-period-canceled together for access purposes) — for targeting, a canceled member is never lumped in with an active one, even though they still hold their perks today.
The billing seam (Stripe later)
Every write in this feature is a recorded fact about something that already happened elsewhere, never a charge. Two columns exist specifically so that changes later:
membership_plans.billing_provider— aCHECKconstrained to the single value'merchant'. The API doesn’t even accept this field on create or patch; it’s server-forced. A future sub-project can widen the constraint (e.g. to also allow'stripe') and wire real billing with no schema rework.member_subscriptions.billing_ref— free text today (a merchant’s own reference, if they want one), never validated or dereferenced. The same column becomes a real Stripe subscription id once billing_provider widens.
Nothing about activate/renew/cancel/lapse changes shape when that happens — the seam is there so platform billing can land as a later sub-project, not a rewrite.
API reference
| Method | Path | Auth |
|---|---|---|
POST |
/v1/membership-plans |
dashboard or API key — campaigns:manage |
GET |
/v1/membership-plans / /v1/membership-plans/:id |
dashboard or API key — campaigns:read |
PATCH |
/v1/membership-plans/:id |
dashboard or API key — campaigns:manage (draft only) |
POST |
/v1/membership-plans/:id/activate / :id/archive |
dashboard or API key — campaigns:manage |
POST |
/v1/members/:id/subscriptions |
dashboard, till, or API key — members:write |
GET |
/v1/subscriptions?plan_id=&member_id=&status= |
dashboard or API key — members:read OR campaigns:read |
POST |
/v1/subscriptions/:id/renew |
dashboard, till, or API key — members:write, requires Idempotency-Key |
POST |
/v1/subscriptions/:id/cancel |
dashboard, till, or API key — members:write |
GET |
/v1/me/membership |
consumer session — honest {subscription: null} when there is none |
GET |
/v1/portal/plans?t=<org-slug> |
public, no auth — the active-plans catalog |
Full request/response schemas are in the API reference under the Memberships tag (merchant/API-key surface) and the MemberPortal tag (the two consumer/public reads).
v1 limits
- No platform billing. Nothing here ever charges a card or holds money — see The billing seam.
- No proration. Renewing early simply extends the period further from its existing end date; there’s no partial-period math.
- One live subscription per plan per member — but a member can hold live subscriptions to different plans at the same time.
- A member’s paid tier can only elevate, never demote — see Paid tier.
GRACE_DAYS(7) and the sweep’s cadence aren’t tenant-configurable — they’re fixed constants today.
Next steps
- API reference — the full schema for every
/v1/membership-plans,/v1/subscriptions,/v1/me/membership, and/v1/portal/plansendpoint. - Programs and Rewards — the tier ladder a plan’s paid-tier perk points into, and the reward catalog member-only gating applies to.
- Challenges and Streaks — the challenge catalog member-only gating applies to, and the nightly reward sweep the lapse sweep rides alongside.
- Member Portal — the join pitch and status card described above, in context with the rest of the member-facing app.
infra/DEPLOY.md— deploy-time setup (no new secrets for this feature — see its §8 smoke item).