simple-module-billing 0.0.8__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. simple_module_billing-0.0.8/.gitignore +53 -0
  2. simple_module_billing-0.0.8/PKG-INFO +211 -0
  3. simple_module_billing-0.0.8/README.md +185 -0
  4. simple_module_billing-0.0.8/docs/api-reference.md +202 -0
  5. simple_module_billing-0.0.8/docs/architecture.md +175 -0
  6. simple_module_billing-0.0.8/docs/index.md +59 -0
  7. simple_module_billing-0.0.8/docs/operations.md +122 -0
  8. simple_module_billing-0.0.8/docs/user-guide.md +124 -0
  9. simple_module_billing-0.0.8/package.json +17 -0
  10. simple_module_billing-0.0.8/pyproject.toml +54 -0
  11. simple_module_billing-0.0.8/sm_billing/__init__.py +0 -0
  12. simple_module_billing-0.0.8/sm_billing/admin.py +236 -0
  13. simple_module_billing-0.0.8/sm_billing/checkout.py +203 -0
  14. simple_module_billing-0.0.8/sm_billing/cli.py +44 -0
  15. simple_module_billing-0.0.8/sm_billing/components/AdminNav.tsx +44 -0
  16. simple_module_billing-0.0.8/sm_billing/components/AssignDialog.tsx +87 -0
  17. simple_module_billing-0.0.8/sm_billing/components/CurrentPlanCard.tsx +86 -0
  18. simple_module_billing-0.0.8/sm_billing/components/LimitRows.tsx +80 -0
  19. simple_module_billing-0.0.8/sm_billing/components/PlanCard.tsx +82 -0
  20. simple_module_billing-0.0.8/sm_billing/components/PlanEditor.tsx +213 -0
  21. simple_module_billing-0.0.8/sm_billing/components/PlansTable.tsx +78 -0
  22. simple_module_billing-0.0.8/sm_billing/components/StatusBadge.tsx +7 -0
  23. simple_module_billing-0.0.8/sm_billing/components/SubscriptionsTable.tsx +86 -0
  24. simple_module_billing-0.0.8/sm_billing/constants.py +112 -0
  25. simple_module_billing-0.0.8/sm_billing/context.py +68 -0
  26. simple_module_billing-0.0.8/sm_billing/contracts/__init__.py +0 -0
  27. simple_module_billing-0.0.8/sm_billing/contracts/provider.py +107 -0
  28. simple_module_billing-0.0.8/sm_billing/crypto.py +89 -0
  29. simple_module_billing-0.0.8/sm_billing/deps.py +67 -0
  30. simple_module_billing-0.0.8/sm_billing/endpoints/__init__.py +0 -0
  31. simple_module_billing-0.0.8/sm_billing/endpoints/admin_api.py +93 -0
  32. simple_module_billing-0.0.8/sm_billing/endpoints/admin_views.py +70 -0
  33. simple_module_billing-0.0.8/sm_billing/endpoints/api.py +61 -0
  34. simple_module_billing-0.0.8/sm_billing/endpoints/routers.py +26 -0
  35. simple_module_billing-0.0.8/sm_billing/endpoints/views.py +56 -0
  36. simple_module_billing-0.0.8/sm_billing/endpoints/webhooks.py +27 -0
  37. simple_module_billing-0.0.8/sm_billing/entitlements.py +30 -0
  38. simple_module_billing-0.0.8/sm_billing/errors.py +41 -0
  39. simple_module_billing-0.0.8/sm_billing/hooks/useCheckoutPolling.ts +50 -0
  40. simple_module_billing-0.0.8/sm_billing/lifecycle.py +74 -0
  41. simple_module_billing-0.0.8/sm_billing/models.py +112 -0
  42. simple_module_billing-0.0.8/sm_billing/module.py +132 -0
  43. simple_module_billing-0.0.8/sm_billing/pages/Billing.tsx +201 -0
  44. simple_module_billing-0.0.8/sm_billing/pages/Connection.tsx +219 -0
  45. simple_module_billing-0.0.8/sm_billing/pages/Plans.tsx +122 -0
  46. simple_module_billing-0.0.8/sm_billing/pages/Subscriptions.tsx +123 -0
  47. simple_module_billing-0.0.8/sm_billing/plans.py +227 -0
  48. simple_module_billing-0.0.8/sm_billing/providers/__init__.py +0 -0
  49. simple_module_billing-0.0.8/sm_billing/providers/factory.py +47 -0
  50. simple_module_billing-0.0.8/sm_billing/providers/manual.py +60 -0
  51. simple_module_billing-0.0.8/sm_billing/providers/stripe.py +209 -0
  52. simple_module_billing-0.0.8/sm_billing/reconcile.py +71 -0
  53. simple_module_billing-0.0.8/sm_billing/resolve.py +30 -0
  54. simple_module_billing-0.0.8/sm_billing/schemas.py +222 -0
  55. simple_module_billing-0.0.8/sm_billing/seats.py +63 -0
  56. simple_module_billing-0.0.8/sm_billing/services.py +36 -0
  57. simple_module_billing-0.0.8/sm_billing/settings.py +47 -0
  58. simple_module_billing-0.0.8/sm_billing/startup.py +27 -0
  59. simple_module_billing-0.0.8/sm_billing/sync.py +120 -0
  60. simple_module_billing-0.0.8/sm_billing/utils/api.test.ts +57 -0
  61. simple_module_billing-0.0.8/sm_billing/utils/api.ts +95 -0
  62. simple_module_billing-0.0.8/sm_billing/utils/format.test.ts +125 -0
  63. simple_module_billing-0.0.8/sm_billing/utils/format.ts +91 -0
  64. simple_module_billing-0.0.8/sm_billing/utils/plan-form.test.ts +180 -0
  65. simple_module_billing-0.0.8/sm_billing/utils/plan-form.ts +204 -0
  66. simple_module_billing-0.0.8/sm_billing/utils/seats.test.ts +22 -0
  67. simple_module_billing-0.0.8/sm_billing/utils/seats.ts +5 -0
  68. simple_module_billing-0.0.8/sm_billing/utils/types.ts +87 -0
  69. simple_module_billing-0.0.8/sm_billing/webhooks.py +128 -0
  70. simple_module_billing-0.0.8/tests/api_helpers.py +26 -0
  71. simple_module_billing-0.0.8/tests/conftest.py +96 -0
  72. simple_module_billing-0.0.8/tests/fake_provider.py +88 -0
  73. simple_module_billing-0.0.8/tests/helpers.py +41 -0
  74. simple_module_billing-0.0.8/tests/test_admin_api.py +223 -0
  75. simple_module_billing-0.0.8/tests/test_billing_api.py +238 -0
  76. simple_module_billing-0.0.8/tests/test_crypto.py +50 -0
  77. simple_module_billing-0.0.8/tests/test_entitlements.py +101 -0
  78. simple_module_billing-0.0.8/tests/test_lifecycle.py +75 -0
  79. simple_module_billing-0.0.8/tests/test_models.py +65 -0
  80. simple_module_billing-0.0.8/tests/test_module.py +59 -0
  81. simple_module_billing-0.0.8/tests/test_plans.py +141 -0
  82. simple_module_billing-0.0.8/tests/test_providers.py +70 -0
  83. simple_module_billing-0.0.8/tests/test_qa_fixes.py +215 -0
  84. simple_module_billing-0.0.8/tests/test_review_fixes.py +266 -0
  85. simple_module_billing-0.0.8/tests/test_seats.py +129 -0
  86. simple_module_billing-0.0.8/tests/test_stripe_provider.py +266 -0
  87. simple_module_billing-0.0.8/tests/test_webhooks.py +208 -0
  88. simple_module_billing-0.0.8/tsconfig.json +10 -0
@@ -0,0 +1,53 @@
1
+ .claude/settings.local.json
2
+
3
+ # Python
4
+ .venv/
5
+ __pycache__/
6
+ *.pyc
7
+ *.egg-info/
8
+
9
+ # Node
10
+ node_modules/
11
+ dist/
12
+
13
+ # Built module frontend bundles (force-included into wheels at build time)
14
+ modules/*/*/static/dist/
15
+
16
+ # Host build output and the module-pages manifest regenerated by `make gen-pages`
17
+ host/static/dist/
18
+ host/client_app/modules.manifest.json
19
+ host/client_app/modules.generated.ts
20
+ host/client_app/modules.generated.css
21
+ host/client_app/modules.assets.json
22
+
23
+ # Local databases. `make migrate` resolves alembic's script_location relative
24
+ # to the repo root, so running it the documented way leaves ./app.db here —
25
+ # not under host/ — and it must never be committed.
26
+ host/*.db
27
+ app.db*
28
+ # SQLite runs in WAL mode since framework 0.0.35 (#339): its sidecar files.
29
+ host/*.db-wal
30
+ host/*.db-shm
31
+
32
+ # Runtime upload storage (PagebuilderSettings.media_root)
33
+ var/
34
+ # file_storage's local backend (DEFAULT_FS_ROOT = ./uploads), which the dev
35
+ # host creates in the repo root
36
+ uploads/
37
+
38
+ # Env
39
+ .env
40
+
41
+ # Playwright
42
+ playwright-report/
43
+ test-results/
44
+ .playwright-mcp/
45
+
46
+ # macOS
47
+ .DS_Store
48
+
49
+ # QA / verify pipeline artifacts (/qa, /vf)
50
+ .qa/
51
+ .verify/
52
+ # Scratch database a /qa run boots against, so it never touches host/app.db.
53
+ qa.db*
@@ -0,0 +1,211 @@
1
+ Metadata-Version: 2.5
2
+ Name: simple_module_billing
3
+ Version: 0.0.8
4
+ Summary: Plans, subscriptions and Stripe billing for multi-tenant SimpleModule apps
5
+ Project-URL: Repository, https://github.com/antosubash/simple_module_python_modules
6
+ Author-email: Anto Subash <antosubash@live.com>
7
+ License-Expression: MIT
8
+ Keywords: billing,saas,simple-module,stripe,subscriptions,tenants
9
+ Requires-Python: >=3.12
10
+ Requires-Dist: cryptography>=42
11
+ Requires-Dist: pydantic-settings>=2.0
12
+ Requires-Dist: simple-module-core<0.1,>=0.0.35
13
+ Requires-Dist: simple-module-db<0.1,>=0.0.35
14
+ Requires-Dist: simple-module-hosting<0.1,>=0.0.35
15
+ Requires-Dist: simple-module-inertia<0.1,>=0.0.35
16
+ Requires-Dist: simple-module-settings<0.1,>=0.0.35
17
+ Requires-Dist: simple-module-tenants<0.1,>=0.0.35
18
+ Requires-Dist: sqlalchemy>=2.0
19
+ Requires-Dist: stripe<17,>=16
20
+ Requires-Dist: typer>=0.12
21
+ Provides-Extra: dev
22
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
23
+ Requires-Dist: pytest>=8.0; extra == 'dev'
24
+ Requires-Dist: simple-module-test<0.1,>=0.0.35; extra == 'dev'
25
+ Description-Content-Type: text/markdown
26
+
27
+ # simple_module_billing
28
+
29
+ Plans, subscriptions and Stripe payments for multi-tenant SimpleModule apps.
30
+
31
+ Billing is the commercial layer on top of the framework's `tenants` module.
32
+ It changes none of the tenants code. Instead it plugs into the seams that
33
+ module exposes:
34
+
35
+ - **Entitlements.** Billing installs `PlanEntitlements` as
36
+ `app.state.tenants.entitlements`. A tenant's plan then answers
37
+ `limit(tenant_id, key)` and `has_feature(tenant_id, key)`. The seat limit
38
+ that `tenants` already enforces on members and invitations (HTTP 402) is
39
+ taken from the plan's `tenants.seats`.
40
+ - **Lifecycle.** When a subscription becomes `unpaid`, billing suspends the
41
+ tenant through `TenantService.set_status`. When payment recovers, billing
42
+ reactivates it. A tenant that an admin suspended by hand is never
43
+ reactivated by billing.
44
+ - **Events.** On `MembershipAdded` and `MembershipRemoved`, billing keeps the
45
+ Stripe quantity of a per-seat plan equal to the member count.
46
+
47
+ **Full documentation** lives in [`docs/`](docs/index.md):
48
+ [user guide](docs/user-guide.md) (admin screens and the owner's Billing page),
49
+ [API reference](docs/api-reference.md) (endpoints, errors, gating a feature on
50
+ a plan), [operations](docs/operations.md) (install, Stripe setup,
51
+ reconcile, troubleshooting) and [architecture](docs/architecture.md).
52
+
53
+ ## Install
54
+
55
+ ```bash
56
+ pip install simple_module_billing
57
+ ```
58
+
59
+ The package depends on `simple_module_tenants>=0.0.35`. The host has to run
60
+ with `SM_MULTI_TENANT=true`, because the tenant billing screen needs an active
61
+ organisation. Billing ships SQLModel tables but no migrations. Autogenerate a
62
+ revision in the host and give its first revision
63
+ `branch_labels = ("billing",)`, so the module can be removed on its own with
64
+ `alembic downgrade billing@base`.
65
+
66
+ On first start, billing seeds a free default plan (`free`). It is idempotent,
67
+ so a fresh install entitles every tenant before an admin has opened the plan
68
+ editor.
69
+
70
+ ## Usage
71
+
72
+ ### Plans
73
+
74
+ Edit plans on **Admin → Billing → Plans** (`/admin/billing/plans`). Each plan
75
+ has the following:
76
+
77
+ | Field | Meaning |
78
+ |---|---|
79
+ | `pricing_model` | `free`, `flat` (one price) or `per_seat` (the Stripe quantity tracks members) |
80
+ | `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. |
81
+ | `amount_month` / `amount_year` | Minor units, for display only. Stripe charges whatever the price says. |
82
+ | `trial_days` | Trial length passed to Checkout. Free plans have no trial. |
83
+ | `limits` | `{key: int}`. **A key that is not set means unlimited; `0` forbids.** `tenants.seats` caps members plus pending invitations. |
84
+ | `features` | Keys that `has_feature` answers `true` for |
85
+
86
+ A Stripe price that a live subscription is billed on cannot be swapped or
87
+ removed (`409 price_in_use`): webhooks map a subscription to its plan through
88
+ its price. Create a new plan for new pricing instead. For the same reason, do
89
+ not let the Customer Portal switch customers to prices that belong to no plan.
90
+
91
+ Exactly one plan is the default. It must be free, and it can be neither
92
+ unset nor archived. To change the default, mark another free plan as default.
93
+ Plans are archived, never deleted. Tenants already on an archived plan keep
94
+ it, but it no longer appears in the plan picker.
95
+
96
+ A tenant's plan is decided like this:
97
+
98
+ - If the subscription status is `trialing`, `active` or `past_due`, the
99
+ tenant gets the subscribed plan.
100
+ - In every other case (no subscription, `canceled`, `incomplete`, `unpaid`),
101
+ the tenant gets the default plan.
102
+
103
+ Consumers ask the `tenants` protocol and never import billing:
104
+
105
+ ```python
106
+ limit = await request.app.state.tenants.entitlements.limit(tenant_id, "storage.gb")
107
+ ```
108
+
109
+ ### Providers
110
+
111
+ Billing settings are stored in the database. They are registered with
112
+ `register_module_settings` and read no environment variables, so a
113
+ `SM_BILLING_*` variable has no effect.
114
+
115
+ | Setting | Notes |
116
+ |---|---|
117
+ | `provider` | `manual` (the default) or `stripe`. Read at startup, so changing it needs a restart. |
118
+ | `stripe_secret_key` | `sk_…`. Stored as `enc:v1:<fernet>`, encrypted with the app's `secret_key`. |
119
+ | `stripe_webhook_secret` | `whsec_…`, encrypted the same way. |
120
+ | `return_base_url` | Origin that Stripe returns visitors to. If empty, the request's own origin is used. |
121
+
122
+ - **manual.** There is no payment. Admins assign a plan and status on
123
+ **Admin → Billing → Subscriptions**. Setting `unpaid` suspends the tenant.
124
+ - **stripe.** Payment uses Stripe Checkout (first paid plan), the Customer
125
+ Portal (cards, invoices, cancellation) and webhooks. If you choose `stripe`
126
+ without both secrets, the app still boots on `manual`, and the Connection
127
+ screen explains why.
128
+
129
+ ### Stripe setup
130
+
131
+ 1. Create a Product and recurring Prices in Stripe. Put their IDs on the
132
+ plans.
133
+ 2. Enter the secret key and webhook secret on **Admin → Billing → Stripe
134
+ connection** (`/admin/billing/connection`). This screen encrypts them.
135
+ Saving new secrets rebuilds the provider immediately.
136
+ 3. Add a webhook endpoint at `https://<host>/billing/webhooks/stripe` for
137
+ `checkout.session.completed` and `customer.subscription.created|updated|deleted`.
138
+ 4. Under **Settings → Billing → Subscriptions → Manage failed payments**,
139
+ choose **"mark the subscription as unpaid"**. This setting is what makes
140
+ billing allow a grace period and then suspend. If it is set to "cancel",
141
+ the tenant drops to the free plan instead of being suspended.
142
+ 5. Enable the Customer Portal (Settings → Billing → Customer portal).
143
+
144
+ To try it locally:
145
+
146
+ ```bash
147
+ stripe listen --forward-to localhost:8000/billing/webhooks/stripe
148
+ ```
149
+
150
+ A tenant gets one trial: after any earlier Stripe subscription, Checkout runs
151
+ without `trial_days`. Starting a new Checkout expires the tenant's previous
152
+ open session, and Checkout is refused while Stripe still lists a live
153
+ subscription for the customer, so two tabs cannot buy two subscriptions.
154
+
155
+ The owner of an organisation that billing suspended can still open
156
+ `/billing/` in a pay-only "restore" mode (status and Customer Portal, no plan
157
+ changes); paying there reactivates it through the next webhook. An
158
+ organisation an admin suspended stays closed.
159
+
160
+ Webhooks are verified, deduplicated by event id, and never trusted for state:
161
+ each one re-fetches the subscription from Stripe, so events that arrive out
162
+ of order are harmless. A failed delivery records its error in
163
+ `billing_webhook_event` and returns 500, so Stripe retries it. A subscription
164
+ that maps to no tenant (another product on the same Stripe account, or a
165
+ tenant since deleted) is recorded and acknowledged with 200 instead, so it
166
+ cannot get the endpoint disabled. A cancellation or `unpaid` whose price no
167
+ plan knows is still applied, on the plan the tenant had.
168
+
169
+ ### Reconcile
170
+
171
+ ```bash
172
+ smpy billing reconcile [--tenant ID]
173
+ ```
174
+
175
+ Re-fetches every Stripe subscription and pushes any drift in per-seat
176
+ quantity. It is safe to run while the app is live and safe to run twice. Use
177
+ it after missed webhooks or a Stripe outage.
178
+
179
+ ## Permissions
180
+
181
+ | Permission | Granted to |
182
+ |---|---|
183
+ | `billing.view` | tenant `owner`, `admin` |
184
+ | `billing.manage` | tenant `owner`. Checkout, plan changes and the portal also require the owner role in the active tenant. |
185
+ | `billing.platform.view` / `billing.platform.manage` | platform admins (the `admin` role holds `*`) |
186
+
187
+ ## Routes
188
+
189
+ | Route | What |
190
+ |---|---|
191
+ | `GET /billing/` | Tenant screen: current plan, seats, plan picker, portal |
192
+ | `GET /api/billing/status` | Plan, subscription, seats, provider |
193
+ | `POST /api/billing/checkout` `{plan_id, interval}` | Returns a Checkout `{url}` (first paid plan only) |
194
+ | `POST /api/billing/portal` | Returns a Customer Portal `{url}` |
195
+ | `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. |
196
+ | `/admin/billing/plans`, `/subscriptions`, `/connection` | Admin screens |
197
+ | `/api/billing/admin/plans` … `/subscriptions/{tenant_id}/assign` … `/connection` | Admin API |
198
+ | `POST /billing/webhooks/stripe` | Public. The signature is the authentication. |
199
+
200
+ Every API mutation needs the `X-CSRF-Token` header, which views expose as
201
+ the `csrf_token` prop.
202
+
203
+ ## Known limits
204
+
205
+ - **Deleting a tenant.** `tenants` publishes no `TenantDeleted` event, and
206
+ billing's `tenant_id` is a plain column (one module cannot hold a foreign
207
+ key into another module's tables). Deleting a tenant therefore neither
208
+ cancels its Stripe subscription nor removes its billing rows. Cancel the
209
+ subscription in Stripe first.
210
+ - **Not in v1:** per-tenant limit overrides, coupons, usage metering, more
211
+ than one subscription per tenant, and translated UI text.
@@ -0,0 +1,185 @@
1
+ # simple_module_billing
2
+
3
+ Plans, subscriptions and Stripe payments for multi-tenant SimpleModule apps.
4
+
5
+ Billing is the commercial layer on top of the framework's `tenants` module.
6
+ It changes none of the tenants code. Instead it plugs into the seams that
7
+ module exposes:
8
+
9
+ - **Entitlements.** Billing installs `PlanEntitlements` as
10
+ `app.state.tenants.entitlements`. A tenant's plan then answers
11
+ `limit(tenant_id, key)` and `has_feature(tenant_id, key)`. The seat limit
12
+ that `tenants` already enforces on members and invitations (HTTP 402) is
13
+ taken from the plan's `tenants.seats`.
14
+ - **Lifecycle.** When a subscription becomes `unpaid`, billing suspends the
15
+ tenant through `TenantService.set_status`. When payment recovers, billing
16
+ reactivates it. A tenant that an admin suspended by hand is never
17
+ reactivated by billing.
18
+ - **Events.** On `MembershipAdded` and `MembershipRemoved`, billing keeps the
19
+ Stripe quantity of a per-seat plan equal to the member count.
20
+
21
+ **Full documentation** lives in [`docs/`](docs/index.md):
22
+ [user guide](docs/user-guide.md) (admin screens and the owner's Billing page),
23
+ [API reference](docs/api-reference.md) (endpoints, errors, gating a feature on
24
+ a plan), [operations](docs/operations.md) (install, Stripe setup,
25
+ reconcile, troubleshooting) and [architecture](docs/architecture.md).
26
+
27
+ ## Install
28
+
29
+ ```bash
30
+ pip install simple_module_billing
31
+ ```
32
+
33
+ The package depends on `simple_module_tenants>=0.0.35`. The host has to run
34
+ with `SM_MULTI_TENANT=true`, because the tenant billing screen needs an active
35
+ organisation. Billing ships SQLModel tables but no migrations. Autogenerate a
36
+ revision in the host and give its first revision
37
+ `branch_labels = ("billing",)`, so the module can be removed on its own with
38
+ `alembic downgrade billing@base`.
39
+
40
+ On first start, billing seeds a free default plan (`free`). It is idempotent,
41
+ so a fresh install entitles every tenant before an admin has opened the plan
42
+ editor.
43
+
44
+ ## Usage
45
+
46
+ ### Plans
47
+
48
+ Edit plans on **Admin → Billing → Plans** (`/admin/billing/plans`). Each plan
49
+ has the following:
50
+
51
+ | Field | Meaning |
52
+ |---|---|
53
+ | `pricing_model` | `free`, `flat` (one price) or `per_seat` (the Stripe quantity tracks members) |
54
+ | `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. |
55
+ | `amount_month` / `amount_year` | Minor units, for display only. Stripe charges whatever the price says. |
56
+ | `trial_days` | Trial length passed to Checkout. Free plans have no trial. |
57
+ | `limits` | `{key: int}`. **A key that is not set means unlimited; `0` forbids.** `tenants.seats` caps members plus pending invitations. |
58
+ | `features` | Keys that `has_feature` answers `true` for |
59
+
60
+ A Stripe price that a live subscription is billed on cannot be swapped or
61
+ removed (`409 price_in_use`): webhooks map a subscription to its plan through
62
+ its price. Create a new plan for new pricing instead. For the same reason, do
63
+ not let the Customer Portal switch customers to prices that belong to no plan.
64
+
65
+ Exactly one plan is the default. It must be free, and it can be neither
66
+ unset nor archived. To change the default, mark another free plan as default.
67
+ Plans are archived, never deleted. Tenants already on an archived plan keep
68
+ it, but it no longer appears in the plan picker.
69
+
70
+ A tenant's plan is decided like this:
71
+
72
+ - If the subscription status is `trialing`, `active` or `past_due`, the
73
+ tenant gets the subscribed plan.
74
+ - In every other case (no subscription, `canceled`, `incomplete`, `unpaid`),
75
+ the tenant gets the default plan.
76
+
77
+ Consumers ask the `tenants` protocol and never import billing:
78
+
79
+ ```python
80
+ limit = await request.app.state.tenants.entitlements.limit(tenant_id, "storage.gb")
81
+ ```
82
+
83
+ ### Providers
84
+
85
+ Billing settings are stored in the database. They are registered with
86
+ `register_module_settings` and read no environment variables, so a
87
+ `SM_BILLING_*` variable has no effect.
88
+
89
+ | Setting | Notes |
90
+ |---|---|
91
+ | `provider` | `manual` (the default) or `stripe`. Read at startup, so changing it needs a restart. |
92
+ | `stripe_secret_key` | `sk_…`. Stored as `enc:v1:<fernet>`, encrypted with the app's `secret_key`. |
93
+ | `stripe_webhook_secret` | `whsec_…`, encrypted the same way. |
94
+ | `return_base_url` | Origin that Stripe returns visitors to. If empty, the request's own origin is used. |
95
+
96
+ - **manual.** There is no payment. Admins assign a plan and status on
97
+ **Admin → Billing → Subscriptions**. Setting `unpaid` suspends the tenant.
98
+ - **stripe.** Payment uses Stripe Checkout (first paid plan), the Customer
99
+ Portal (cards, invoices, cancellation) and webhooks. If you choose `stripe`
100
+ without both secrets, the app still boots on `manual`, and the Connection
101
+ screen explains why.
102
+
103
+ ### Stripe setup
104
+
105
+ 1. Create a Product and recurring Prices in Stripe. Put their IDs on the
106
+ plans.
107
+ 2. Enter the secret key and webhook secret on **Admin → Billing → Stripe
108
+ connection** (`/admin/billing/connection`). This screen encrypts them.
109
+ Saving new secrets rebuilds the provider immediately.
110
+ 3. Add a webhook endpoint at `https://<host>/billing/webhooks/stripe` for
111
+ `checkout.session.completed` and `customer.subscription.created|updated|deleted`.
112
+ 4. Under **Settings → Billing → Subscriptions → Manage failed payments**,
113
+ choose **"mark the subscription as unpaid"**. This setting is what makes
114
+ billing allow a grace period and then suspend. If it is set to "cancel",
115
+ the tenant drops to the free plan instead of being suspended.
116
+ 5. Enable the Customer Portal (Settings → Billing → Customer portal).
117
+
118
+ To try it locally:
119
+
120
+ ```bash
121
+ stripe listen --forward-to localhost:8000/billing/webhooks/stripe
122
+ ```
123
+
124
+ A tenant gets one trial: after any earlier Stripe subscription, Checkout runs
125
+ without `trial_days`. Starting a new Checkout expires the tenant's previous
126
+ open session, and Checkout is refused while Stripe still lists a live
127
+ subscription for the customer, so two tabs cannot buy two subscriptions.
128
+
129
+ The owner of an organisation that billing suspended can still open
130
+ `/billing/` in a pay-only "restore" mode (status and Customer Portal, no plan
131
+ changes); paying there reactivates it through the next webhook. An
132
+ organisation an admin suspended stays closed.
133
+
134
+ Webhooks are verified, deduplicated by event id, and never trusted for state:
135
+ each one re-fetches the subscription from Stripe, so events that arrive out
136
+ of order are harmless. A failed delivery records its error in
137
+ `billing_webhook_event` and returns 500, so Stripe retries it. A subscription
138
+ that maps to no tenant (another product on the same Stripe account, or a
139
+ tenant since deleted) is recorded and acknowledged with 200 instead, so it
140
+ cannot get the endpoint disabled. A cancellation or `unpaid` whose price no
141
+ plan knows is still applied, on the plan the tenant had.
142
+
143
+ ### Reconcile
144
+
145
+ ```bash
146
+ smpy billing reconcile [--tenant ID]
147
+ ```
148
+
149
+ Re-fetches every Stripe subscription and pushes any drift in per-seat
150
+ quantity. It is safe to run while the app is live and safe to run twice. Use
151
+ it after missed webhooks or a Stripe outage.
152
+
153
+ ## Permissions
154
+
155
+ | Permission | Granted to |
156
+ |---|---|
157
+ | `billing.view` | tenant `owner`, `admin` |
158
+ | `billing.manage` | tenant `owner`. Checkout, plan changes and the portal also require the owner role in the active tenant. |
159
+ | `billing.platform.view` / `billing.platform.manage` | platform admins (the `admin` role holds `*`) |
160
+
161
+ ## Routes
162
+
163
+ | Route | What |
164
+ |---|---|
165
+ | `GET /billing/` | Tenant screen: current plan, seats, plan picker, portal |
166
+ | `GET /api/billing/status` | Plan, subscription, seats, provider |
167
+ | `POST /api/billing/checkout` `{plan_id, interval}` | Returns a Checkout `{url}` (first paid plan only) |
168
+ | `POST /api/billing/portal` | Returns a Customer Portal `{url}` |
169
+ | `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. |
170
+ | `/admin/billing/plans`, `/subscriptions`, `/connection` | Admin screens |
171
+ | `/api/billing/admin/plans` … `/subscriptions/{tenant_id}/assign` … `/connection` | Admin API |
172
+ | `POST /billing/webhooks/stripe` | Public. The signature is the authentication. |
173
+
174
+ Every API mutation needs the `X-CSRF-Token` header, which views expose as
175
+ the `csrf_token` prop.
176
+
177
+ ## Known limits
178
+
179
+ - **Deleting a tenant.** `tenants` publishes no `TenantDeleted` event, and
180
+ billing's `tenant_id` is a plain column (one module cannot hold a foreign
181
+ key into another module's tables). Deleting a tenant therefore neither
182
+ cancels its Stripe subscription nor removes its billing rows. Cancel the
183
+ subscription in Stripe first.
184
+ - **Not in v1:** per-tenant limit overrides, coupons, usage metering, more
185
+ than one subscription per tenant, and translated UI text.
@@ -0,0 +1,202 @@
1
+ # Billing API reference
2
+
3
+ All routes are JSON. Every `POST`/`PUT` needs the session's CSRF token in an
4
+ `X-CSRF-Token` header; the billing pages expose it as the `csrf_token` Inertia
5
+ prop (`simple_module_hosting.csrf.get_csrf_token`). The webhook is the only
6
+ exception. Errors are `{"detail": "<code>", …}`; the full table is at the end.
7
+
8
+ ## Gating a feature on a plan (from another module)
9
+
10
+ Never import billing. Ask the `tenants` entitlement seam, which billing fills
11
+ in at startup (without billing it answers "unlimited, every feature"):
12
+
13
+ ```python
14
+ from tenants.contracts.entitlements import ensure_within_limit
15
+
16
+ entitlements = request.app.state.tenants.entitlements
17
+
18
+ # A count-based limit: raises EntitlementExceededError → HTTP 402
19
+ # {"detail": "plan_limit", "key": "storage.gb", "limit": 5}
20
+ await ensure_within_limit(entitlements, tenant_id, "storage.gb", current=used, adding=1)
21
+
22
+ # A yes/no feature
23
+ if not await entitlements.has_feature(tenant_id, "sso"):
24
+ raise HTTPException(403, "Your plan does not include single sign-on.")
25
+
26
+ # The raw number: None = unlimited, 0 = forbidden
27
+ limit = await entitlements.limit(tenant_id, "exports.per_month")
28
+ ```
29
+
30
+ Name keys `<module>.<thing>` (`storage.gb`, `exports.per_month`). An
31
+ administrator then sets them per plan in the plan editor's Limits and Features.
32
+ To have a key suggested in the editor, add it to `KNOWN_LIMIT_KEYS` in
33
+ `sm_billing/constants.py`.
34
+
35
+ ## Tenant API — `/api/billing`
36
+
37
+ The organisation is always the session's **active tenant**; no route takes a
38
+ tenant id from the client.
39
+
40
+ ### `GET /api/billing/status`
41
+
42
+ Permission: `billing.view` (tenant owner and admin). Also answers for the owner
43
+ of an organisation billing suspended (restore mode).
44
+
45
+ ```json
46
+ {
47
+ "plan": {"id": 2, "key": "team", "name": "Team", "pricing_model": "per_seat",
48
+ "currency": "eur", "amount_month": 900, "amount_year": null,
49
+ "stripe_price_month": "price_…", "stripe_price_year": null,
50
+ "trial_days": 14, "limits": {"tenants.seats": 10}, "features": ["sso"],
51
+ "description": "", "is_default": false, "is_public": true,
52
+ "sort_order": 1, "archived_at": null},
53
+ "subscription": {"status": "active", "interval": "month", "quantity": 4,
54
+ "trial_end": null, "current_period_end": "2026-11-01T00:00:00Z",
55
+ "cancel_at_period_end": false, "has_provider_subscription": true},
56
+ "seats": {"used": 4, "limit": 10},
57
+ "provider": "stripe",
58
+ "checkout_available": true,
59
+ "portal_available": true
60
+ }
61
+ ```
62
+
63
+ `plan` is the plan **in force** (the default plan when the subscription has
64
+ lapsed). `subscription` is `null` for an organisation that never subscribed.
65
+ `seats.used` counts members plus pending invitations — the number the seat
66
+ limit is enforced against.
67
+
68
+ ### `POST /api/billing/checkout`
69
+
70
+ Permission: `billing.manage` and tenant role `owner`. Body
71
+ `{"plan_id": 2, "interval": "month" | "year"}` (interval defaults to `month`).
72
+ Returns `{"url": "https://checkout.stripe.com/…"}`; redirect the browser there.
73
+ Nothing is granted until Stripe's webhook arrives.
74
+
75
+ Creates the Stripe customer on first use, expires the organisation's previous
76
+ open Checkout session, applies the plan's trial only if the organisation never
77
+ had a Stripe subscription, and sets the quantity to the member count for
78
+ per-seat plans.
79
+
80
+ Errors: `checkout_unavailable` 409 (manual provider), `plan_not_found` 404
81
+ (unknown, archived or hidden plan), `plan_is_free` 400,
82
+ `interval_unavailable` 400, `already_subscribed` 409 (a live subscription
83
+ exists locally or at Stripe — use change-plan), `too_many_members` 409,
84
+ `tenant_owner_required` 403, `provider_error` 502.
85
+
86
+ ### `POST /api/billing/portal`
87
+
88
+ Permission: tenant role `owner` (with `billing.manage`, or restore mode for a
89
+ billing-suspended organisation). No body. Returns `{"url": "https://billing.stripe.com/…"}`.
90
+
91
+ Errors: `checkout_unavailable` 409, `no_customer` 409 (never checked out),
92
+ `tenant_owner_required` 403.
93
+
94
+ ### `POST /api/billing/change-plan`
95
+
96
+ Permission: `billing.manage` and tenant role `owner`. Body as checkout. Paid →
97
+ paid moves the Stripe subscription with proration; → free sets it to cancel at
98
+ period end. The local row is re-synced from Stripe's answer and the new status
99
+ is returned (same shape as `GET /status`).
100
+
101
+ Errors: `no_subscription` 409 (use checkout), `already_on_plan` 409,
102
+ `too_many_members` 409 (`{"detail": "too_many_members", "used": 7, "limit": 5}`),
103
+ plus the checkout errors.
104
+
105
+ ## Admin API — `/api/billing/admin`
106
+
107
+ Read routes need `billing.platform.view`; writes need `billing.platform.manage`.
108
+ Platform admins (role `admin`) hold both.
109
+
110
+ | Method & path | Body | Returns |
111
+ |---|---|---|
112
+ | `GET /plans` | — | every plan, archived included |
113
+ | `POST /plans` | plan (below) | the plan, 201 |
114
+ | `PUT /plans/{id}` | plan (same `key`) | the plan |
115
+ | `POST /plans/{id}/archive` | — | the plan |
116
+ | `GET /subscriptions?status=&offset=` | — | one row per organisation (200 per page) |
117
+ | `POST /subscriptions/{tenant_id}/assign` | `{"plan_id", "status", "interval"?}` | the row — manual provider only |
118
+ | `POST /subscriptions/{tenant_id}/resync` | — | the row — Stripe only |
119
+ | `GET /connection` | — | connection status (never the secrets) |
120
+ | `PUT /connection` | see below | connection status |
121
+
122
+ **Plan body**
123
+
124
+ ```json
125
+ {"key": "team", "name": "Team", "description": "", "pricing_model": "flat",
126
+ "currency": "eur", "amount_month": 1900, "amount_year": 19000,
127
+ "stripe_price_month": "price_…", "stripe_price_year": "price_…",
128
+ "trial_days": 14, "limits": {"tenants.seats": 10}, "features": ["sso"],
129
+ "is_default": false, "is_public": true, "sort_order": 0}
130
+ ```
131
+
132
+ Amounts are minor units (cents), 0–2,000,000,000; limit values the same range;
133
+ `trial_days` 0–730; `sort_order` ±1,000,000. Plan keys match
134
+ `^[a-z0-9][a-z0-9_-]{0,63}$`; limit keys and features match
135
+ `^[a-z0-9][a-z0-9_.-]{0,99}$`. A blank price is stored as `null`. Out-of-range
136
+ or malformed values are a 422. Reads never re-apply these bounds, so a row
137
+ stored before a bound existed still lists.
138
+
139
+ **Subscription row**
140
+
141
+ ```json
142
+ {"tenant_id": "…", "tenant_name": "Acme", "tenant_slug": "acme",
143
+ "tenant_status": "active", "members": 4, "plan_id": 2, "plan_key": "team",
144
+ "plan_name": "Team", "status": "active", "interval": "month", "quantity": 4,
145
+ "current_period_end": "…", "cancel_at_period_end": false,
146
+ "suspended_by_billing": false, "provider_subscription_id": "sub_…",
147
+ "synced_at": "…"}
148
+ ```
149
+
150
+ **Connection body** — `{"stripe_secret_key": "", "stripe_webhook_secret": "",
151
+ "clear_secret_key": false, "clear_webhook_secret": false,
152
+ "return_base_url": "https://app.example.com"}`. A blank secret keeps the stored
153
+ one; `clear_*` removes it. Secrets are trimmed and stored encrypted. The
154
+ response is `{"provider", "active_provider", "provider_error",
155
+ "has_secret_key", "has_webhook_secret", "return_base_url", "webhook_path"}`.
156
+
157
+ ## Webhook — `POST /billing/webhooks/stripe`
158
+
159
+ Public (exempt from login), no CSRF; authenticated by the `Stripe-Signature`
160
+ header. Returns 404 unless the Stripe provider is running.
161
+
162
+ | Response | Meaning | Stripe |
163
+ |---|---|---|
164
+ | 400 `invalid_signature` | bad or missing signature | gives up on this delivery |
165
+ | 200 `processed` | subscription re-fetched and applied | done |
166
+ | 200 `duplicate` | event id already processed | done |
167
+ | 200 `ignored` | not an event billing handles | done |
168
+ | 200 `ignored_unknown_tenant` | subscription belongs to no organisation here | done; reason kept on the event row |
169
+ | 200 `ignored_nothing_to_apply` | unknown price on a lapsed subscription we never had | done; reason kept |
170
+ | 500 `processing_failed` | anything else (Stripe unreachable, unknown price on a live subscription…) | retries with backoff |
171
+
172
+ Handled events: `checkout.session.completed` and
173
+ `customer.subscription.created|updated|deleted`.
174
+
175
+ ## Error codes
176
+
177
+ | `detail` | Status | Where | Meaning |
178
+ |---|---|---|---|
179
+ | `tenant_required` | 403 | tenant API | no active organisation in the session |
180
+ | `tenant_owner_required` | 403 | tenant API | only the owner may do this |
181
+ | `forbidden` | 403 | status, portal | missing `billing.view` / `billing.manage` (other routes return the framework's own 403) |
182
+ | `checkout_unavailable` | 409 | tenant API | manual provider: nothing to pay through |
183
+ | `already_subscribed` | 409 | checkout | a live subscription exists |
184
+ | `no_subscription` | 409 | change-plan | nothing to change yet |
185
+ | `already_on_plan` | 409 | change-plan | target equals current |
186
+ | `no_customer` | 409 | portal | never checked out |
187
+ | `too_many_members` | 409 | checkout, change-plan | `used` exceeds the target's `limit` |
188
+ | `plan_is_free` / `interval_unavailable` | 400 | checkout | wrong target |
189
+ | `plan_not_found` | 404 | both | unknown, archived or hidden |
190
+ | `plan_key_taken` / `price_taken` | 409 | admin plans | uniqueness (also under concurrent creates) |
191
+ | `plan_key_immutable` | 400 | admin plans | key changed on update |
192
+ | `price_in_use` | 409 | admin plans | a live subscription is billed on the price being changed |
193
+ | `paid_plan_needs_price` / `free_plan_has_price` / `free_plan_has_trial` / `default_must_be_free` | 400 | admin plans | plan invariants |
194
+ | `default_required` / `cannot_archive_default` | 409 | admin plans | there must always be one free default |
195
+ | `price_not_found` / `price_mismatch` | 400 | admin plans (Stripe) | `{"price", "reason"}` |
196
+ | `provider_managed` | 409 | assign | Stripe owns subscriptions; use resync |
197
+ | `no_provider_subscription` | 409 | resync | nothing at Stripe to read |
198
+ | `tenant_not_found` | 404 | assign, resync | |
199
+ | `invalid_return_url` | 400 | connection | `{"message"}` says the rule |
200
+ | `provider_unavailable` | 503 | any | no provider built (startup failed) |
201
+ | `provider_error` | 502 | any | Stripe refused; `{"message"}` |
202
+ | `plan_limit` | 402 | other modules | from `tenants`: `{"key", "limit"}` |