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?/2and enforced once in the read model. - Member tier is stored in the database as an enum with exactly two values:
:freeand: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_idin awebhook_eventstable, 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/confirmroute. - 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]), andfetch_subscription/1returns 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_monthly→ 500 cents ($5.00/month)paid_yearly→ 417 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:
- Write a real adapter implementing the
Wisp.Billingbehaviour —create_checkout,handle_webhook_event, andfetch_subscription. - Wire it:
config :wisp, :billing_adapter, YourRealAdapter. - Add webhook signature verification (the stub ignores signatures).
- 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
Subscriptionrecord hascurrent_period_endbut nothing expires or reconciles it — a member marked:paidstays 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.