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.”

A plan can name a tier_id from the program’s own tier ladder. While a member’s subscription is liveactive, 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:

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

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:

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:

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

Next steps