Thesis

Paid memberships in Wisp

3 min read
Guides

Wisp ships a working membership system with three content tiers — but the payment processor is a stub. This post is an honest account of what is wired versus what an operator must connect before charging real money.

What works today

  • Member authentication is fully implemented via magic-link email (Wisp.Members). Readers sign in at /members/log_in.
  • Post visibility gating is fully implemented with three tiers — public, members, and paid — decided by Wisp.Members.can_view?/2 and enforced once in the read model.
  • Member tier is stored in the database as an enum with exactly two values: :free and :paid. There are no tier names, descriptions, or feature flags — everything keys off the binary paid/free status.
  • A Subscription record exists (member_id, plan, status, external_ref, current_period_end).
  • A live MRR gauge (Wisp.Realtime.MrrGauge) tracks revenue in cents and the paid subscriber count in memory, and the admin Pulse dashboard shows live MRR/mo + subscriber count.
  • Webhook idempotency is real: events dedupe on external_id in a webhook_events table, and re-delivered events are no-ops.

What is a STUB (and what you must connect)

There is NO real payment processor. This is Phase-1 demo code.

The billing adapter is a stub at lib/wisp/billing/stub.ex. It is a local simulation:

  • No Stripe, LemonSqueezy, Paddle, or any other processor is integrated.
  • Its "checkout" is a hardcoded 302 redirect to a /billing/stub/confirm route.
  • That confirm route simulates payment completion by constructing a fake webhook payload and processing it immediately. The signature is ignored.
  • It returns deterministic fake session IDs (stub_[plan]_[member_id]_[random]), and fetch_subscription/1 returns a hardcoded active mock status.

There is also no pricing/plans page. There is no /pricing route and no UI to start checkout. A reader who hits a gated post sees the paywall CTA:

This post is for members only. Sign in or subscribe to read more.

…but that link goes to /members/log_in — there is no dedicated subscribe page. The only way to trigger the stub today is for an operator to POST /billing/checkout?plan=paid_monthly directly, which 302-redirects to the confirm route.

Pricing (hardcoded)

Two plans exist, with cents hardcoded in Wisp.Realtime.MrrGauge:

  • paid_monthly500 cents ($5.00/month)
  • paid_yearly417 cents ($50/year, ≈ $4.17/month equivalent)

You can override them via config, but not at runtime — a price change requires a recompile:

config :wisp, Wisp.Realtime.MrrGauge,
plan_cents: %{"paid_monthly" => 1000, "paid_yearly" => 833}

Adding plans means code changes (the MrrGauge mapping, possibly a migration, and the billing controller).

Wiring a real processor

The billing adapter is selected in config/config.exs:

config :wisp, :billing_adapter, Wisp.Billing.Stub

To go live, an operator must:

  1. Write a real adapter implementing the Wisp.Billing behaviour — create_checkout, handle_webhook_event, and fetch_subscription.
  2. Wire it: config :wisp, :billing_adapter, YourRealAdapter.
  3. Add webhook signature verification (the stub ignores signatures).
  4. Ensure your adapter generates unique external_ids — the dedupe is silent, so a duplicated id is a no-op, not an error.

No billing environment variables or Fly secrets are needed today (because nothing real is connected).

Gaps to know about

  • No subscription renewal logic. The Subscription record has current_period_end but nothing expires or reconciles it — a member marked :paid stays paid forever. A real adapter must reconcile renewal events.
  • No member-management UI. Operators cannot upgrade/downgrade, view subscription details, cancel, or handle disputes from the read-only admin; those are direct DB writes.
  • The MRR gauge does not persist. It recomputes from the Subscriptions table on boot, but on restart it zeros out until the first billing event.
  • No membership emails. No mail is sent on a membership-status change.
  • The Content API enforces the same gate — a gated post returns access: false / html: null, and password-protected posts are never served on the Content API.

In short: the membership primitives are real and gating is correct; the payment rail is a stub you replace.