Stripe Subscriptions

Stripe subscriptions as one source of licenses. Users buy a plan through Stripe Checkout and manage it in the Customer Portal. A background poller keeps their subscription, and the license it grants, in sync with no public webhook endpoint.

v1.8.3
Level: Advanced
17
1122
10

Dependencies

stripity_stripe is the Stripe API client. oban runs the event poller, so this feature also adds Oban to the supervision tree.

Oban starts from its config in config.exs .

Licenses

The one change to the license schema: :stripe joins the list of sources. Stripe licenses are read-only in the admin pages automatically, because only manual licenses are editable.

Prices and plans

MyApp.Billing.Stripe is the context. It starts by mapping plans to Stripe Prices and back. A plan lists several Price ids, newest first: the first is sold at checkout, and older ones still map existing subscribers to the plan. Changing a price never strands the people on the old one.

In stub mode, every paid public plan gets a stub price, so the buttons appear locally without any configuration.

The Stripe client

Every Stripe API call lives here, behind two choices:

Normalized returns. No function returns a Stripe.* struct. Each returns a plain map with a fixed shape, so Stripe API changes stay in this module. Normalization also absorbs API differences, like newer API versions moving a subscription's period onto its items. It folds Stripe's two ways of scheduling a cancellation ( cancel_at and cancel_at_period_end ) into one cancel_at .

Stub mode. With no API key, calls return stubs instead of hitting the network. The stub threads the user and the price through the round trip: the checkout session's id encodes both, the session points to a subscription id that encodes the same, and fetching that subscription decodes them. The result is a believable subscription for the right user and plan, entirely offline.

Checkout puts the user id on the session ( client_reference_id ) and on the subscription's metadata. That lets a subscription be traced back to its user even when the Stripe Customer isn't one we created.

list_events/1 reads Stripe's event stream and reports {:error, :cursor_expired} when the cursor event has aged out of Stripe's 30-day retention, so the poller can recover.

Events

handle_event/1 acts on customer.subscription.* events and ignores the rest. It re-fetches the subscription instead of reading the event's copy, since an event is a snapshot that may already be stale.

A new subscription is matched to a user by our Stripe Customer, then by the user id set at checkout. A subscription support creates in the Stripe dashboard for an existing customer still finds its user.

The Oban worker that drains Stripe's event stream every minute:

Oban's unique option covers the cron interval, so a slow poll can't pile up duplicates.

Schemas

The mirror. status is Stripe's own string, so a status Stripe adds later can't break loading. "Live" means Stripe still bills it: active, trialing, or past due.

The billing page

The base feature's /billing page gains checkout and the portal. It offers a plan only when buying it would change something:

A user on a manual Enterprise plan sees nothing to buy. The page also says when a subscription renews, when it ends, or that the last payment failed.

Stripe Checkout sends the user back here. It syncs the subscription straight away so the page reflects the purchase, instead of waiting for the poller.

Routing and configuration

/billing/stripe/return is in the authenticated scope, since it always syncs the signed-in user's subscription. There's no webhook route to expose.

All Stripe settings are read here, at boot, from the environment:

The block is skipped in test, which configures its own.

Oban's stripe queue and the cron entry that runs the poller every minute.

Fixture prices, a fake Stripe client, and testing: :manual so Oban jobs only run when a test asks.

Database

The standard Oban jobs table, with the schema version pinned so upgrades are explicit.

Three Stripe-only tables, so nothing about Stripe lands on users or licenses :

Tests

Stands in for the Stripe client in tests. Each test sets the responses it needs, like a subscription, a checkout session, or a list of events, in its own process. LiveViews the test starts find them through $callers , so async tests stay isolated.

Covers the status mapping, plan changes, cancellations, coexistence with manual licenses, checkout guards, session ownership, and how events find their user.

Covers bootstrapping, draining, and recovering from an expired cursor.