Skip to main content

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 PlanEntitlements as app.state.tenants.entitlements. A tenant's plan then answers limit(tenant_id, key) and has_feature(tenant_id, key). The seat limit that tenants already enforces on members and invitations (HTTP 402) is taken from the plan's tenants.seats.
  • Lifecycle. When a subscription becomes unpaid, billing suspends the tenant through TenantService.set_status. When payment recovers, billing reactivates it. A tenant that an admin suspended by hand is never reactivated by billing.
  • Events. On MembershipAdded and MembershipRemoved, 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, active or past_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 unpaid suspends the tenant.
  • stripe. Payment uses Stripe Checkout (first paid plan), the Customer Portal (cards, invoices, cancellation) and webhooks. If you choose stripe without both secrets, the app still boots on manual, and the Connection screen explains why.

Stripe setup

  1. Create a Product and recurring Prices in Stripe. Put their IDs on the plans.
  2. 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.
  3. Add a webhook endpoint at https://<host>/billing/webhooks/stripe for checkout.session.completed and customer.subscription.created|updated|deleted.
  4. 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.
  5. 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. tenants publishes no TenantDeleted event, and billing's tenant_id is 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.8

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for simple-module-billing 0.0.8
File Size Uploaded
simple_module_billing-0.0.8.tar.gz 88.9 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for simple-module-billing 0.0.8
File Interpreter ABI Platform
simple_module_billing-0.0.8-py3-none-any.whl Python 3 none any Details

Total release size: 171.4 kB

Release files / simple_module_billing-0.0.8.tar.gz

Download URL simple_module_billing-0.0.8.tar.gz
Size 88.9 kB
Tags Source
SHA-256 checksum
How to use checksums
c1f3ecab8063a2a9227976f71eb00efdb9479d821db30dd13d320d006896c29b
BLAKE2b-256 checksum
How to use checksums
6c18ba546918fabab23031d6dd90ba167a82a0a9dc61e79afa8d3c4222d9d350
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 13, 2026.

Transparency log

Release files / simple_module_billing-0.0.8-py3-none-any.whl

Download URL simple_module_billing-0.0.8-py3-none-any.whl
Size 82.5 kB
Tags Python 3
SHA-256 checksum
How to use checksums
4cfd90fc0fea23039f1a5c52d0d8c41b7c8edafcd4104f5f39277b91c1b5c604
BLAKE2b-256 checksum
How to use checksums
efedbe13affbd5cfcde60e362eccb4cd74a74dd7039b21221d27ae0237bbc361
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 13, 2026.

Transparency log

Release history Release notifications | RSS feed

0.0.11

2 release files

0.0.10

2 release files

0.0.9

2 release files

This release

0.0.8 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page