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.
- simple_module_billing-0.0.8/.gitignore +53 -0
- simple_module_billing-0.0.8/PKG-INFO +211 -0
- simple_module_billing-0.0.8/README.md +185 -0
- simple_module_billing-0.0.8/docs/api-reference.md +202 -0
- simple_module_billing-0.0.8/docs/architecture.md +175 -0
- simple_module_billing-0.0.8/docs/index.md +59 -0
- simple_module_billing-0.0.8/docs/operations.md +122 -0
- simple_module_billing-0.0.8/docs/user-guide.md +124 -0
- simple_module_billing-0.0.8/package.json +17 -0
- simple_module_billing-0.0.8/pyproject.toml +54 -0
- simple_module_billing-0.0.8/sm_billing/__init__.py +0 -0
- simple_module_billing-0.0.8/sm_billing/admin.py +236 -0
- simple_module_billing-0.0.8/sm_billing/checkout.py +203 -0
- simple_module_billing-0.0.8/sm_billing/cli.py +44 -0
- simple_module_billing-0.0.8/sm_billing/components/AdminNav.tsx +44 -0
- simple_module_billing-0.0.8/sm_billing/components/AssignDialog.tsx +87 -0
- simple_module_billing-0.0.8/sm_billing/components/CurrentPlanCard.tsx +86 -0
- simple_module_billing-0.0.8/sm_billing/components/LimitRows.tsx +80 -0
- simple_module_billing-0.0.8/sm_billing/components/PlanCard.tsx +82 -0
- simple_module_billing-0.0.8/sm_billing/components/PlanEditor.tsx +213 -0
- simple_module_billing-0.0.8/sm_billing/components/PlansTable.tsx +78 -0
- simple_module_billing-0.0.8/sm_billing/components/StatusBadge.tsx +7 -0
- simple_module_billing-0.0.8/sm_billing/components/SubscriptionsTable.tsx +86 -0
- simple_module_billing-0.0.8/sm_billing/constants.py +112 -0
- simple_module_billing-0.0.8/sm_billing/context.py +68 -0
- simple_module_billing-0.0.8/sm_billing/contracts/__init__.py +0 -0
- simple_module_billing-0.0.8/sm_billing/contracts/provider.py +107 -0
- simple_module_billing-0.0.8/sm_billing/crypto.py +89 -0
- simple_module_billing-0.0.8/sm_billing/deps.py +67 -0
- simple_module_billing-0.0.8/sm_billing/endpoints/__init__.py +0 -0
- simple_module_billing-0.0.8/sm_billing/endpoints/admin_api.py +93 -0
- simple_module_billing-0.0.8/sm_billing/endpoints/admin_views.py +70 -0
- simple_module_billing-0.0.8/sm_billing/endpoints/api.py +61 -0
- simple_module_billing-0.0.8/sm_billing/endpoints/routers.py +26 -0
- simple_module_billing-0.0.8/sm_billing/endpoints/views.py +56 -0
- simple_module_billing-0.0.8/sm_billing/endpoints/webhooks.py +27 -0
- simple_module_billing-0.0.8/sm_billing/entitlements.py +30 -0
- simple_module_billing-0.0.8/sm_billing/errors.py +41 -0
- simple_module_billing-0.0.8/sm_billing/hooks/useCheckoutPolling.ts +50 -0
- simple_module_billing-0.0.8/sm_billing/lifecycle.py +74 -0
- simple_module_billing-0.0.8/sm_billing/models.py +112 -0
- simple_module_billing-0.0.8/sm_billing/module.py +132 -0
- simple_module_billing-0.0.8/sm_billing/pages/Billing.tsx +201 -0
- simple_module_billing-0.0.8/sm_billing/pages/Connection.tsx +219 -0
- simple_module_billing-0.0.8/sm_billing/pages/Plans.tsx +122 -0
- simple_module_billing-0.0.8/sm_billing/pages/Subscriptions.tsx +123 -0
- simple_module_billing-0.0.8/sm_billing/plans.py +227 -0
- simple_module_billing-0.0.8/sm_billing/providers/__init__.py +0 -0
- simple_module_billing-0.0.8/sm_billing/providers/factory.py +47 -0
- simple_module_billing-0.0.8/sm_billing/providers/manual.py +60 -0
- simple_module_billing-0.0.8/sm_billing/providers/stripe.py +209 -0
- simple_module_billing-0.0.8/sm_billing/reconcile.py +71 -0
- simple_module_billing-0.0.8/sm_billing/resolve.py +30 -0
- simple_module_billing-0.0.8/sm_billing/schemas.py +222 -0
- simple_module_billing-0.0.8/sm_billing/seats.py +63 -0
- simple_module_billing-0.0.8/sm_billing/services.py +36 -0
- simple_module_billing-0.0.8/sm_billing/settings.py +47 -0
- simple_module_billing-0.0.8/sm_billing/startup.py +27 -0
- simple_module_billing-0.0.8/sm_billing/sync.py +120 -0
- simple_module_billing-0.0.8/sm_billing/utils/api.test.ts +57 -0
- simple_module_billing-0.0.8/sm_billing/utils/api.ts +95 -0
- simple_module_billing-0.0.8/sm_billing/utils/format.test.ts +125 -0
- simple_module_billing-0.0.8/sm_billing/utils/format.ts +91 -0
- simple_module_billing-0.0.8/sm_billing/utils/plan-form.test.ts +180 -0
- simple_module_billing-0.0.8/sm_billing/utils/plan-form.ts +204 -0
- simple_module_billing-0.0.8/sm_billing/utils/seats.test.ts +22 -0
- simple_module_billing-0.0.8/sm_billing/utils/seats.ts +5 -0
- simple_module_billing-0.0.8/sm_billing/utils/types.ts +87 -0
- simple_module_billing-0.0.8/sm_billing/webhooks.py +128 -0
- simple_module_billing-0.0.8/tests/api_helpers.py +26 -0
- simple_module_billing-0.0.8/tests/conftest.py +96 -0
- simple_module_billing-0.0.8/tests/fake_provider.py +88 -0
- simple_module_billing-0.0.8/tests/helpers.py +41 -0
- simple_module_billing-0.0.8/tests/test_admin_api.py +223 -0
- simple_module_billing-0.0.8/tests/test_billing_api.py +238 -0
- simple_module_billing-0.0.8/tests/test_crypto.py +50 -0
- simple_module_billing-0.0.8/tests/test_entitlements.py +101 -0
- simple_module_billing-0.0.8/tests/test_lifecycle.py +75 -0
- simple_module_billing-0.0.8/tests/test_models.py +65 -0
- simple_module_billing-0.0.8/tests/test_module.py +59 -0
- simple_module_billing-0.0.8/tests/test_plans.py +141 -0
- simple_module_billing-0.0.8/tests/test_providers.py +70 -0
- simple_module_billing-0.0.8/tests/test_qa_fixes.py +215 -0
- simple_module_billing-0.0.8/tests/test_review_fixes.py +266 -0
- simple_module_billing-0.0.8/tests/test_seats.py +129 -0
- simple_module_billing-0.0.8/tests/test_stripe_provider.py +266 -0
- simple_module_billing-0.0.8/tests/test_webhooks.py +208 -0
- 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"}` |
|