simple_module_billing
Plans, subscriptions and Stripe payments for multi-tenant SimpleModule apps.
Billing is the commercial layer on top of the framework's tenants module.
It changes none of the tenants code. Instead it plugs into the seams that
module exposes:
- Entitlements. Billing installs
PlanEntitlementsasapp.state.tenants.entitlements. A tenant's plan then answerslimit(tenant_id, key)andhas_feature(tenant_id, key). The seat limit thattenantsalready enforces on members and invitations (HTTP 402) is taken from the plan'stenants.seats. - Lifecycle. When a subscription becomes
unpaid, billing suspends the tenant throughTenantService.set_status. When payment recovers, billing reactivates it. A tenant that an admin suspended by hand is never reactivated by billing. - Events. On
MembershipAddedandMembershipRemoved, billing keeps the Stripe quantity of a per-seat plan equal to the member count.
Full documentation lives in docs/:
user guide (admin screens and the owner's Billing page),
API reference (endpoints, errors, gating a feature on
a plan), operations (install, Stripe setup,
reconcile, troubleshooting) and architecture.
Install
pip install simple_module_billing
The package depends on simple_module_tenants>=0.0.35. The host has to run
with SM_MULTI_TENANT=true, because the tenant billing screen needs an active
organisation. Billing ships SQLModel tables but no migrations. Autogenerate a
revision in the host and give its first revision
branch_labels = ("billing",), so the module can be removed on its own with
alembic downgrade billing@base.
On first start, billing seeds a free default plan (free). It is idempotent,
so a fresh install entitles every tenant before an admin has opened the plan
editor.
Usage
Plans
Edit plans on Admin → Billing → Plans (/admin/billing/plans). Each plan
has the following:
| Field | Meaning |
|---|---|
pricing_model |
free, flat (one price) or per_seat (the Stripe quantity tracks members) |
stripe_price_month / stripe_price_year |
Stripe Price IDs. Under Stripe they are checked on save to exist, be active, and match the plan's currency and interval. |
amount_month / amount_year |
Minor units, for display only. Stripe charges whatever the price says. |
trial_days |
Trial length passed to Checkout. Free plans have no trial. |
limits |
{key: int}. A key that is not set means unlimited; 0 forbids. tenants.seats caps members plus pending invitations. |
features |
Keys that has_feature answers true for |
A Stripe price that a live subscription is billed on cannot be swapped or
removed (409 price_in_use): webhooks map a subscription to its plan through
its price. Create a new plan for new pricing instead. For the same reason, do
not let the Customer Portal switch customers to prices that belong to no plan.
Exactly one plan is the default. It must be free, and it can be neither unset nor archived. To change the default, mark another free plan as default. Plans are archived, never deleted. Tenants already on an archived plan keep it, but it no longer appears in the plan picker.
A tenant's plan is decided like this:
- If the subscription status is
trialing,activeorpast_due, the tenant gets the subscribed plan. - In every other case (no subscription,
canceled,incomplete,unpaid), the tenant gets the default plan.
Consumers ask the tenants protocol and never import billing:
limit = await request.app.state.tenants.entitlements.limit(tenant_id, "storage.gb")
Providers
Billing settings are stored in the database. They are registered with
register_module_settings and read no environment variables, so a
SM_BILLING_* variable has no effect.
| Setting | Notes |
|---|---|
provider |
manual (the default) or stripe. Read at startup, so changing it needs a restart. |
stripe_secret_key |
sk_…. Stored as enc:v1:<fernet>, encrypted with the app's secret_key. |
stripe_webhook_secret |
whsec_…, encrypted the same way. |
return_base_url |
Origin that Stripe returns visitors to. If empty, the request's own origin is used. |
- manual. There is no payment. Admins assign a plan and status on
Admin → Billing → Subscriptions. Setting
unpaidsuspends the tenant. - stripe. Payment uses Stripe Checkout (first paid plan), the Customer
Portal (cards, invoices, cancellation) and webhooks. If you choose
stripewithout both secrets, the app still boots onmanual, and the Connection screen explains why.
Stripe setup
- Create a Product and recurring Prices in Stripe. Put their IDs on the plans.
- Enter the secret key and webhook secret on Admin → Billing → Stripe
connection (
/admin/billing/connection). This screen encrypts them. Saving new secrets rebuilds the provider immediately. - Add a webhook endpoint at
https://<host>/billing/webhooks/stripeforcheckout.session.completedandcustomer.subscription.created|updated|deleted. - Under Settings → Billing → Subscriptions → Manage failed payments, choose "mark the subscription as unpaid". This setting is what makes billing allow a grace period and then suspend. If it is set to "cancel", the tenant drops to the free plan instead of being suspended.
- Enable the Customer Portal (Settings → Billing → Customer portal).
To try it locally:
stripe listen --forward-to localhost:8000/billing/webhooks/stripe
A tenant gets one trial: after any earlier Stripe subscription, Checkout runs
without trial_days. Starting a new Checkout expires the tenant's previous
open session, and Checkout is refused while Stripe still lists a live
subscription for the customer, so two tabs cannot buy two subscriptions.
The owner of an organisation that billing suspended can still open
/billing/ in a pay-only "restore" mode (status and Customer Portal, no plan
changes); paying there reactivates it through the next webhook. An
organisation an admin suspended stays closed.
Webhooks are verified, deduplicated by event id, and never trusted for state:
each one re-fetches the subscription from Stripe, so events that arrive out
of order are harmless. A failed delivery records its error in
billing_webhook_event and returns 500, so Stripe retries it. A subscription
that maps to no tenant (another product on the same Stripe account, or a
tenant since deleted) is recorded and acknowledged with 200 instead, so it
cannot get the endpoint disabled. A cancellation or unpaid whose price no
plan knows is still applied, on the plan the tenant had.
Reconcile
smpy billing reconcile [--tenant ID]
Re-fetches every Stripe subscription and pushes any drift in per-seat quantity. It is safe to run while the app is live and safe to run twice. Use it after missed webhooks or a Stripe outage.
Permissions
| Permission | Granted to |
|---|---|
billing.view |
tenant owner, admin |
billing.manage |
tenant owner. Checkout, plan changes and the portal also require the owner role in the active tenant. |
billing.platform.view / billing.platform.manage |
platform admins (the admin role holds *) |
Routes
| Route | What |
|---|---|
GET /billing/ |
Tenant screen: current plan, seats, plan picker, portal |
GET /api/billing/status |
Plan, subscription, seats, provider |
POST /api/billing/checkout {plan_id, interval} |
Returns a Checkout {url} (first paid plan only) |
POST /api/billing/portal |
Returns a Customer Portal {url} |
POST /api/billing/change-plan {plan_id, interval} |
Paid → paid with proration; paid → free cancels at period end. Returns 409 too_many_members if the target's seats are fewer than those in use. |
/admin/billing/plans, /subscriptions, /connection |
Admin screens |
/api/billing/admin/plans … /subscriptions/{tenant_id}/assign … /connection |
Admin API |
POST /billing/webhooks/stripe |
Public. The signature is the authentication. |
Every API mutation needs the X-CSRF-Token header, which views expose as
the csrf_token prop.
Known limits
- Deleting a tenant.
tenantspublishes noTenantDeletedevent, and billing'stenant_idis a plain column (one module cannot hold a foreign key into another module's tables). Deleting a tenant therefore neither cancels its Stripe subscription nor removes its billing rows. Cancel the subscription in Stripe first. - Not in v1: per-tenant limit overrides, coupons, usage metering, more than one subscription per tenant, and translated UI text.
Metadata
Release files for simple-module-billing 0.0.11
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| simple_module_billing-0.0.11.tar.gz | 100.7 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| simple_module_billing-0.0.11-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 189.6 kB
Release files / simple_module_billing-0.0.11.tar.gz
| Download URL | simple_module_billing-0.0.11.tar.gz |
|---|---|
| Size | 100.7 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
2f68479d72cef6e975ffaa410731539d81e16ba8894636fbb7135c50bf5679c9
|
|
BLAKE2b-256 checksum How to use checksums |
559986a209255dff746d4e188f7324910b1be41f2fa5b4bef97122615fb091a1
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 18, 2026.
Transparency logRelease files / simple_module_billing-0.0.11-py3-none-any.whl
| Download URL | simple_module_billing-0.0.11-py3-none-any.whl |
|---|---|
| Size | 89.0 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
c1436fb06b7fe9548a2b1b074bcccd452956efefdd5c5c6d78b72a7944d149e5
|
|
BLAKE2b-256 checksum How to use checksums |
579adb8c8c9cda6055cb85b35fc45971ec2de5fbcb7ec2430215748097aaa55d
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 18, 2026.
Transparency log