Billing
Billing code tends to spread. The plan names live in the payment provider’s dashboard, the checks for “may this account export” live in handlers, the menu shows an entry the plan does not allow, and a quota is counted in a way two concurrent requests can both pass.
sofie treats what a plan allows as data the framework knows, like a flag or a permission. A module declares its entitlements as constants, one module declares the plans that grant them, and routes, menus and screens require them the same way they require a flag. The payment provider only bills; the app’s own database decides what an owner may do, and without a provider plans, trials and quotas still work.
Billing is the sofie-billing crate, behind the billing feature of sofie.
Entitlements
Section titled “Entitlements”An entitlement is what a plan grants, named <module>.<name>. There are three kinds:
| Kind | Means | Example |
|---|---|---|
Entitlement |
A switch: the plan has it or not | Exporting |
Limit |
At most N of something the app counts itself | Projects kept |
Quota |
At most N uses per period (Day, Week, Month, Year) |
API calls a month |
pub const EXPORT: Entitlement = Entitlement::new("project.export");pub const PROJECTS: Limit = Limit::new("project.projects");pub const CALLS: Quota = Quota::new("project.api_calls", Period::Month);A quota’s period counts from the subscription’s start, so a month runs from its billing day; an owner without one counts calendar periods in UTC.
An entitlement is neither a permission, which a role grants, nor a feature flag, which a rule switches. The three can gate the same route side by side.
One module’s src/plans.rs declares the plans and lists them in PLANS:
pub const FREE: Plan = Plan::new("free") .limits(&[(PROJECTS, 1)]) .quotas(&[(CALLS, 2)]);
pub const PRO: Plan = Plan::new("pro") .grants(&[EXPORT]) .limits(&[(PROJECTS, UNLIMITED)]) .quotas(&[(CALLS, 3)]) .trial_days(14) .per_seat();
pub const PLANS: Plans = Plans::new(FREE, &[PRO]).products(&[CREDITS]);- The first plan is the default every owner without a subscription holds.
- A limit the plan leaves out allows none;
UNLIMITEDmeans no end. PLANSis checked as the constant is built, so a malformed name or a repeat fails the build.Products are one-off purchases, such as credits, listed with.products(…).
sofie new plan MODULE name adds a plan to src/plans.rs and turns on the billing feature.
sofie gen then installs it: sofie::billing::install(&db, &PLANS) in the app and the worker,
Gates::entitlements, its migrations and its webhook.
Checking entitlements
Section titled “Checking entitlements”On routes, menus and screens
Section titled “On routes, menus and screens”routes.get("/export", export).entitled(EXPORT);An owner whose plan lacks it gets a 403. A menu entry follows its route, so it is hidden for the same owners. A screen asks in its template:
{#if entitled("project.export")}<p>Export</p>{:else}<p>Upgrade to export</p>{/if}A limit or quota above zero counts as held. The principal’s plan is read once for a request that needs it.
sofie check (rule entitlement) reports an .entitled(…) or a screen’s entitled("…") that
names no entitlement, limit or quota a module declares.
In handlers
Section titled “In handlers”A handler takes Entitlements as an argument; the owner is the area’s principal.
async fn second_project(entitlements: Entitlements) -> Result<()> { entitlements.allow_another(PROJECTS, 1)}
async fn call(entitlements: Entitlements) -> Result<Consumed> { entitlements.consume(CALLS, 1).await}allow_another(limit, count)takes how many the owner has now, which the app counts, and refuses when one more would pass the plan’s limit.consume(quota, amount)returns an outcome that counts the use with the handler’s commit, so a refused action uses nothing. It is written as an upsert that refuses to pass the allowance, so racing requests cannot overspend.used(quota)reads the use in the current period;plan(),seats()andstanding()read the owner’s plan, seats and subscription.
Trials
Section titled “Trials”async fn start_trial(me: Principal<Members>, billing: Billing) -> Result<TrialStarted> { billing.start_trial(me.id().into(), PRO).await}A trial needs no card and is given once per owner. Its length is the plan’s trial_days. It
starts when the handler returns the TrialStarted outcome.
Paying: Checkout
Section titled “Paying: Checkout”Checkout sends a subscriber to the provider’s pages. Each method takes an explicit Subscriber,
so it works from any context, and returns a Link, which a route with .then_follow() sends the
browser to:
async fn upgrade(me: Principal<Members>, checkout: Checkout) -> Result<Link> { checkout.subscribe(me.id().into(), PRO, 2, "/members/plan").await}
async fn manage(me: Principal<Members>, checkout: Checkout) -> Result<Link> { checkout.portal(me.id().into(), "/members/plan").await}
routes.post("/upgrade", upgrade).then_follow();routes.post("/portal", manage).then_follow();| Method | Does |
|---|---|
subscribe(subscriber, plan, seats, return_path) |
Pays for a plan; refused while a plan already renews at the provider |
buy(subscriber, product, quantity, return_path) |
Pays once for a product |
portal(subscriber, return_path) |
Opens the provider’s page to manage what the subscriber pays for |
Providers
Section titled “Providers”SOFIE_BILLING_PROVIDER chooses stripe or paddle:
| Setting | Holds |
|---|---|
SOFIE_BILLING_API_KEY |
The provider’s API key |
SOFIE_BILLING_WEBHOOK_SECRET |
The secret its notifications are signed with |
SOFIE_BILLING_PRICES |
Each plan’s or product’s price, such as pro=price_… |
SOFIE_BILLING_BASE_URL |
Where the provider returns the browser, else SOFIE_BASE_URL |
SOFIE_BILLING_API_URL |
The provider’s API, for a test server |
Calls to the provider go through Outgoing, so they are allow-listed, bounded and traced.
Webhooks
Section titled “Webhooks”The provider’s notifications arrive at /webhooks/billing. Each is verified by the provider’s HMAC
within five minutes and handled once per delivery id.
A subscription follows the states trialing, active, past_due and canceled as the provider
reports them, unless the move is one the states do not allow. A change older than the one held is
dropped, so notifications arriving out of order do no harm. The plan is kept while the provider
retries a failed payment.
SubscriptionChanged and PurchaseCompleted are raised for the app’s listeners. A price that
SOFIE_BILLING_PRICES does not name is logged as an error with its id and acknowledged: the
subscription keeps its plan, and a new subscription or purchase grants nothing.
A plan’s seats are unlimited by default, fixed with .seats(n), or bought with .per_seat(), as
many as the subscription’s quantity. What counts as a seat is the app’s.
Billing::seats(subscriber) and Entitlements::seats() return Seats::Unlimited or
Seats::AtMost(n), and seats.allows(filled) tells whether one more fits. sofie-tenancy uses this
to limit an organization’s members.
Billing::change_seats changes the provider’s subscription quantity. It is sent by a job after
the commit and prorated, safe to retry: to Stripe with the job’s idempotency key, to Paddle as a
count that changes nothing when sent twice. The seats held follow the provider’s notification, not
the request.
Billing::cancel_at_provider cancels at once, for an owner that is gone.
Trade-offs
Section titled “Trade-offs”- An owner holds one subscription.
Entitlementstakes the area’s principal as the owner;CheckoutandBillingtake an explicitSubscriber, which is how sofie-tenancy bills an organization. - Stripe and Paddle are the only providers.
- No billing screens ship with the framework; the app writes its plan and upgrade pages.
