Stripe Billing

The design leans on two ideas worth understanding up front:

v1.8.3
Level: Advanced
18
1143
0

Dependencies

stripity_stripe is the Stripe API client. oban powers the background event poller — the billing feature introduces Oban to the app, so it's also wired into the supervision tree and config.

The Billing context

The public API. The reads ( active_subscription/1 , subscribed?/1 , current_plan/1 ) answer "what does this user have access to right now."

ensure_customer/1 is the lazy-customer pattern: it returns the user's stored stripe_customer_id , creating the Stripe Customer and persisting the id on first use. Checkout and the portal both go through it, so a customer is created at most once.

The two sync paths both converge on upsert_subscription/2 :

upsert_subscription/2 upserts on the Stripe subscription id, which is what makes those two paths idempotent: they can race, run twice, or arrive out of order, and the row converges to whatever Stripe last reported. Note that event handling re-fetches the subscription from Stripe rather than trusting the event payload — events are point-in-time snapshots that may already be stale by the time they're processed.

The plan catalog

Plan metadata — name, blurb, displayed price, feature list — lives in code so it's versioned and easy to reason about. The live Stripe Price id is pulled from config instead, because it differs per environment and Stripe account and shouldn't be hard-coded:

purchasable/0 returns only the plans whose Price id is configured. This is what makes stub mode graceful — an unconfigured plan simply shows no buy button. for_price_id/1 maps a stored subscription's price back to its plan, which is how the UI knows which plan a user is on.

The Stripe wrapper

Every Stripe API call lives here, behind two deliberate design choices:

Normalized returns. No function ever leaks a raw Stripe.* struct to the rest of the app. Stripe's struct shapes shift between API versions (the location of a subscription's price and period end, for example), so each function returns a plain map with a fixed shape. All the version churn is contained to this one module, and the context and tests work against a stable contract.

Stub mode. configured?/0 checks for an API key. Without one, each function returns a stub instead of hitting the network. The clever bit is that the stub threads the selected price through the round-trip : create_checkout_session/5 encodes the price id into the stub session id, fetch_checkout_session/1 derives a stub subscription id from it, and fetch_subscription/1 decodes the price back out. The result is a fully attributed, believable subscription — so you can click subscribe → return → "subscribed" entirely offline.

list_events/1 is the read side of the poller: it pages Stripe's /v1/events , newest first, using an ending_before cursor, and normalizes each event to a string-keyed map so handlers can pattern-match on "type" .

The Subscription schema

A thin mirror of a Stripe Subscription. The status enum tracks the Stripe statuses we care about ( active , trialing , past_due , canceled , incomplete ); the context layer folds the rarer Stripe statuses ( unpaid , incomplete_expired ) into these.

active?/1 is the access predicate — it returns true for active , trialing , and past_due . Treating past_due as still-active gives users a grace period while a failed payment retries, rather than cutting them off the moment a card is declined.

The event poller

This is what makes the feature webhook-free. An Oban cron entry runs it every minute; it drains Stripe's event stream and dispatches each event to MyApp.Billing.handle_stripe_event/1 .

The checkpoint logic has two modes. On the first run (no checkpoint) it bootstraps : it records the latest event id without processing anything, so it never replays the account's entire history. On subsequent runs it drains : it fetches everything newer than the cursor, processes oldest-first (so a partial crash leaves the oldest unprocessed event as the next target), then advances the checkpoint.

The unique clause spans the 60-second cron interval (doubled for safety). If a poll is still running when the next tick fires, the duplicate is dropped instead of piling up.

The cursor store. One row per source, keyed by source_key/1 . update/2 upserts and stamps last_polled_at , which doubles as a liveness signal — even a quiet stream refreshes the timestamp so you can tell the poller is alive.

The Checkout return

Stripe redirects the user back to /billing/return?session_id=... after paying. The controller polls the session immediately via sync_checkout_session/2 and flashes the result, so the happy path doesn't depend on the event poller having run yet. A :pending result (payment still processing) gets a softer message.

The billing page

A single LiveView at /billing . It shows the current subscription with a status badge and renewal line, a "Manage billing" button that opens the Stripe Customer Portal, and the list of purchasable plans with subscribe/switch buttons.

Both subscribe and manage follow the same shape: ask the context for a Stripe-hosted URL, then redirect(socket, external: url) . The LiveView itself never renders a card form — all sensitive payment UI is hosted by Stripe.

Routing and configuration

/billing (the LiveView) and /billing/return (the controller) both sit inside the authenticated scope — billing always operates on the logged-in user. There's no webhook route to expose, because sync happens through polling.

The Oban config: a stripe_sync queue and a cron plugin that enqueues the poller every minute.

Oban is added to the supervision tree, started from the config above.

Stripe secrets come from the environment. STRIPE_SECRET_KEY is read in any environment but only applied when present — so leaving it unset keeps dev and test in stub mode. The publishable key and per-plan Price ids are wired in for production.

Stub Price ids so the subscribe flow is fully clickable in dev without real keys.

Test Price ids plus config :my_app, Oban, testing: :manual , which disables the cron and queues during tests so jobs don't fire on their own.

The stripe_customer_id field is added to the User schema so the lazy-customer lookup in ensure_customer/1 can read and write it.

Database

Three changes:

The standard Oban jobs table. Oban.Migration.up(version: 14) pins the schema version so upgrades are explicit rather than implicit.