simple-module-billing 0.0.8__py3-none-any.whl
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.dist-info/METADATA +211 -0
- simple_module_billing-0.0.8.dist-info/RECORD +64 -0
- simple_module_billing-0.0.8.dist-info/WHEEL +4 -0
- simple_module_billing-0.0.8.dist-info/entry_points.txt +5 -0
- sm_billing/__init__.py +0 -0
- sm_billing/admin.py +236 -0
- sm_billing/checkout.py +203 -0
- sm_billing/cli.py +44 -0
- sm_billing/components/AdminNav.tsx +44 -0
- sm_billing/components/AssignDialog.tsx +87 -0
- sm_billing/components/CurrentPlanCard.tsx +86 -0
- sm_billing/components/LimitRows.tsx +80 -0
- sm_billing/components/PlanCard.tsx +82 -0
- sm_billing/components/PlanEditor.tsx +213 -0
- sm_billing/components/PlansTable.tsx +78 -0
- sm_billing/components/StatusBadge.tsx +7 -0
- sm_billing/components/SubscriptionsTable.tsx +86 -0
- sm_billing/constants.py +112 -0
- sm_billing/context.py +68 -0
- sm_billing/contracts/__init__.py +0 -0
- sm_billing/contracts/provider.py +107 -0
- sm_billing/crypto.py +89 -0
- sm_billing/deps.py +67 -0
- sm_billing/endpoints/__init__.py +0 -0
- sm_billing/endpoints/admin_api.py +93 -0
- sm_billing/endpoints/admin_views.py +70 -0
- sm_billing/endpoints/api.py +61 -0
- sm_billing/endpoints/routers.py +26 -0
- sm_billing/endpoints/views.py +56 -0
- sm_billing/endpoints/webhooks.py +27 -0
- sm_billing/entitlements.py +30 -0
- sm_billing/errors.py +41 -0
- sm_billing/hooks/useCheckoutPolling.ts +50 -0
- sm_billing/lifecycle.py +74 -0
- sm_billing/models.py +112 -0
- sm_billing/module.py +132 -0
- sm_billing/package.json +17 -0
- sm_billing/pages/Billing.tsx +201 -0
- sm_billing/pages/Connection.tsx +219 -0
- sm_billing/pages/Plans.tsx +122 -0
- sm_billing/pages/Subscriptions.tsx +123 -0
- sm_billing/plans.py +227 -0
- sm_billing/providers/__init__.py +0 -0
- sm_billing/providers/factory.py +47 -0
- sm_billing/providers/manual.py +60 -0
- sm_billing/providers/stripe.py +209 -0
- sm_billing/reconcile.py +71 -0
- sm_billing/resolve.py +30 -0
- sm_billing/schemas.py +222 -0
- sm_billing/seats.py +63 -0
- sm_billing/services.py +36 -0
- sm_billing/settings.py +47 -0
- sm_billing/startup.py +27 -0
- sm_billing/sync.py +120 -0
- sm_billing/utils/api.test.ts +57 -0
- sm_billing/utils/api.ts +95 -0
- sm_billing/utils/format.test.ts +125 -0
- sm_billing/utils/format.ts +91 -0
- sm_billing/utils/plan-form.test.ts +180 -0
- sm_billing/utils/plan-form.ts +204 -0
- sm_billing/utils/seats.test.ts +22 -0
- sm_billing/utils/seats.ts +5 -0
- sm_billing/utils/types.ts +87 -0
- sm_billing/webhooks.py +128 -0
|
@@ -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,64 @@
|
|
|
1
|
+
sm_billing/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
sm_billing/admin.py,sha256=VWDAfDY-TvaitMv908s-rkulXYA9VfPMNRPqxWtlpPE,9342
|
|
3
|
+
sm_billing/checkout.py,sha256=GbwpBSEiK9KP_72ZPAQT2BZZpeXZs8o_Nhbx72aliPw,9046
|
|
4
|
+
sm_billing/cli.py,sha256=s5gSrJwBDimM1JnfV_8JT0Niz0PDKnEu9yFEqbT_ucE,1452
|
|
5
|
+
sm_billing/constants.py,sha256=HsSnisAdCqrr0wgstlyYRyeg9_zAsfM5uyYpi0LCsOg,4013
|
|
6
|
+
sm_billing/context.py,sha256=s2kLlbTpslhxLr8RfFObC8MDTJG9UG3r1dvnJw_5Q6M,2676
|
|
7
|
+
sm_billing/crypto.py,sha256=DpDqr9Cp26CKG3VOEvVh7HiBjGCemb4_kDI8UoxcUQg,3076
|
|
8
|
+
sm_billing/deps.py,sha256=tvycEr5ap8FdQyZ1juCrIbFHzpS0u3Z5fkfWJUprOP0,2211
|
|
9
|
+
sm_billing/entitlements.py,sha256=NAmO9hdF9ZZJSUQZdMsH9osdNa2K02lmFTJJvct53YI,1249
|
|
10
|
+
sm_billing/errors.py,sha256=gH5OXOgO2aOgIM5YS3z7TiclvyA00orzmNVNPxcJSxU,1398
|
|
11
|
+
sm_billing/lifecycle.py,sha256=IqpRk_BBTtbHz9tmtC9YSRk0h8eT2aPkHKdzgWrdNlo,2712
|
|
12
|
+
sm_billing/models.py,sha256=c7xqwB9oC2jPP8Swu3dDWkwnTeL3Wn7MDzchX7rBisM,4966
|
|
13
|
+
sm_billing/module.py,sha256=71dQ3SasQtOQxjLh9iQDZlXaAL69nrqgq3tXEBhPVaE,4871
|
|
14
|
+
sm_billing/plans.py,sha256=VmSvf6m-BA9hwD8mrpXKMgxYHXd0z3IRuzuBCSVnixI,9219
|
|
15
|
+
sm_billing/reconcile.py,sha256=9eYf-yIqXqkOqo4UjOgI9khIPr7FdtcWLyXULjjeawQ,2646
|
|
16
|
+
sm_billing/resolve.py,sha256=r1V5efjMnupgzdmLmCpWvHC1RTkMukPsxOywlMIePNg,1154
|
|
17
|
+
sm_billing/schemas.py,sha256=jkVudM9Jof4CXPbLxUUGJrJgjfMXZArHz56wj4QnoNA,6892
|
|
18
|
+
sm_billing/seats.py,sha256=vJNtRXujBK5fn2qWUNv5UHZrMhmEq7--ok-2glhaVPo,2256
|
|
19
|
+
sm_billing/services.py,sha256=Q2IPNjGeaQYngkSFjFcVqipfjlSXrFdDFGsNvgGAFDQ,1139
|
|
20
|
+
sm_billing/settings.py,sha256=o9IPA--HTE0WJDwsRlHcEomOUsVI22ICXmn9w9vHkOQ,1673
|
|
21
|
+
sm_billing/startup.py,sha256=xoZ-ORfib2HdmkN11mDc1gqeYBsm9VHT_72mxXRkyBQ,876
|
|
22
|
+
sm_billing/sync.py,sha256=rEGORsGfOvL3Dz2oj5MjKHTepG9UmPKoDgEfq_AILLs,4840
|
|
23
|
+
sm_billing/webhooks.py,sha256=7FoTIPYSpomBeVDla5s13FvAPhzMTuHl2bgm8CHstb4,5279
|
|
24
|
+
sm_billing/components/AdminNav.tsx,sha256=s-50gmSZ6EHZD4uLaoEh78ecZYUDSnb3TI1e5Sn7Uug,1657
|
|
25
|
+
sm_billing/components/AssignDialog.tsx,sha256=LRz7QDLloJKobZqbqG0dU8r8C8lKMeVUg33WZeI-Urc,3086
|
|
26
|
+
sm_billing/components/CurrentPlanCard.tsx,sha256=H4kbPB9HYwMoUwfBjDnWYswGQxP4jEqr1Em3g6GjQQc,2993
|
|
27
|
+
sm_billing/components/LimitRows.tsx,sha256=WJZOU1uv3eoKu88febak0FlieQW78oLK6zJ5OQ7viLM,2506
|
|
28
|
+
sm_billing/components/PlanCard.tsx,sha256=A-NMLg-Kqfek4oHKHNoE9DDF37KpoJXLdkfhhfZTSdg,2723
|
|
29
|
+
sm_billing/components/PlanEditor.tsx,sha256=ewXBBwoI8YsIyFlUY4Ng1bIUF6T96IXhfuxcckzSi3Y,7620
|
|
30
|
+
sm_billing/components/PlansTable.tsx,sha256=KqE7lDZSiAtN8J6ctFQWzgXGIDpN8Kl5DJ0Hkp3t0Jk,2668
|
|
31
|
+
sm_billing/components/StatusBadge.tsx,sha256=-Vtuk9GCgmwCvkcTRwb8OMxEE15D9YJi9cK-8EUHg34,343
|
|
32
|
+
sm_billing/components/SubscriptionsTable.tsx,sha256=XwT-SPIOwWhT-VfjL8YGFd-MtGSWlD1HKivnYvcGS-8,3162
|
|
33
|
+
sm_billing/contracts/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
34
|
+
sm_billing/contracts/provider.py,sha256=vxP3gbW9Y1G30_6HRrUlDf63g61Uy4SMlAT6JpxOwks,3078
|
|
35
|
+
sm_billing/endpoints/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
36
|
+
sm_billing/endpoints/admin_api.py,sha256=zfNa0bmI3T8yuRBC1abuxx1NHXUTmYmO8TjX-_NwUSg,3340
|
|
37
|
+
sm_billing/endpoints/admin_views.py,sha256=1wHsq9mIwF_gd2Jl4HUw9HzlgFYIgwHlBkHz7_bH5Zo,2690
|
|
38
|
+
sm_billing/endpoints/api.py,sha256=-Pq0BUhhnUZH7KHvStIRfPvWjOHaiHoR5k9yFllzXhc,2183
|
|
39
|
+
sm_billing/endpoints/routers.py,sha256=zgLomdwxqztZWJOxGbTAlRH_ZJJWI_Pt8OYJswoCgQs,1009
|
|
40
|
+
sm_billing/endpoints/views.py,sha256=Ic4tKYScEjw3qcJu-tKxMmgxAqXzbMMaHbwYYbdhRZg,2221
|
|
41
|
+
sm_billing/endpoints/webhooks.py,sha256=lW-xFHYdqF13Bdtu2Td4btmGDLE1dDeInwMaBxU4Ar4,1009
|
|
42
|
+
sm_billing/hooks/useCheckoutPolling.ts,sha256=fTD0hMxRnyIlEeLZh2ormtA3pn8Z55Mm1ukVDTI-h9I,1466
|
|
43
|
+
sm_billing/pages/Billing.tsx,sha256=Ic-jvLCzDf1hwdPAJfhiaU3SWTtzW2p_Z6nq6IqbVrs,7334
|
|
44
|
+
sm_billing/pages/Connection.tsx,sha256=mPW4R9Xsvwcbd-cm8L7-f_5Iny7kIuRRRIo_izAU_aY,7981
|
|
45
|
+
sm_billing/pages/Plans.tsx,sha256=gEV-TXXuzLdmKw1qcvOWT-loycw08yu3nbPfS-22ZsI,4101
|
|
46
|
+
sm_billing/pages/Subscriptions.tsx,sha256=VB8evz3SB4pmDVR4ykSq8KsNOqIbp8oc3SSBqCkkgnw,4269
|
|
47
|
+
sm_billing/providers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
48
|
+
sm_billing/providers/factory.py,sha256=tFPR9UFXBnZpnQrtfQRojpA1tciQYzlHCBL1CtmL0Pc,1857
|
|
49
|
+
sm_billing/providers/manual.py,sha256=rpu0HRGLYnWDzG8IFQwEUX-6tripRcldZjzTcCfzQPE,1952
|
|
50
|
+
sm_billing/providers/stripe.py,sha256=7T6PqQOi0AcQPEmL76O4FzXc9opLA4bx9e7jNtMl3t4,8808
|
|
51
|
+
sm_billing/utils/api.test.ts,sha256=Nyx4rS2FRFYyMeJIfWKw3BNmMroYnxefNFNZ6NE9vHE,2151
|
|
52
|
+
sm_billing/utils/api.ts,sha256=HSdB4qrNBDFZ8H8kygk0HioY1wBjRQMTeftB_BOZxvA,4300
|
|
53
|
+
sm_billing/utils/format.test.ts,sha256=DE8J7oepBitm19BlTFcmJFhLJl0LS-Rqo1vdnaLevzg,4031
|
|
54
|
+
sm_billing/utils/format.ts,sha256=DuUd03jUFN1L32Kn9MIHpb4aMPdcJ8fixk8PDcJbkg0,3408
|
|
55
|
+
sm_billing/utils/plan-form.test.ts,sha256=8VT1OkaNDRlO7ohU24o2euVBIb57SUAwxc15CYnTN1E,7181
|
|
56
|
+
sm_billing/utils/plan-form.ts,sha256=fuEMy1606uDrTU-ZRGziqglIXOlfaXqdJHEWoAvgT_4,6799
|
|
57
|
+
sm_billing/utils/seats.test.ts,sha256=fDM2p9QkbTQrgwY6-d1W390mOfTYjTGaUOHK5EJxuKg,718
|
|
58
|
+
sm_billing/utils/seats.ts,sha256=PmA3j2zOyJEb7S63YaXEGst5Gp_NfhXlbQulV_tbTcg,308
|
|
59
|
+
sm_billing/utils/types.ts,sha256=_7t0DaUsNbCJcJ6JJ_k89-Un83MJsJKbGn2Mo5-RPgw,2083
|
|
60
|
+
sm_billing/package.json,sha256=It2bnXELrNfl_idc41Gka7AF4jZ-YiVL3Az_baK6KB0,401
|
|
61
|
+
simple_module_billing-0.0.8.dist-info/METADATA,sha256=y3ZDi4DV00q8C3IQWUtS1A2nqF98VNlGCiwyWWUtJQI,9906
|
|
62
|
+
simple_module_billing-0.0.8.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
63
|
+
simple_module_billing-0.0.8.dist-info/entry_points.txt,sha256=0Fb1diONuHnclT1ty50Mupm4pQheofTwKvc-mzO6DOU,120
|
|
64
|
+
simple_module_billing-0.0.8.dist-info/RECORD,,
|
sm_billing/__init__.py
ADDED
|
File without changes
|
sm_billing/admin.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""Platform-admin operations: plan writes with price checks, the tenant list,
|
|
2
|
+
manual assignment, resync, and the Stripe connection settings."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
from sqlalchemy import select
|
|
9
|
+
from tenants.models import Tenant
|
|
10
|
+
|
|
11
|
+
from sm_billing import constants as c
|
|
12
|
+
from sm_billing import crypto
|
|
13
|
+
from sm_billing.contracts.provider import ProviderError
|
|
14
|
+
from sm_billing.errors import BillingError
|
|
15
|
+
from sm_billing.lifecycle import apply_lifecycle, tenant_service
|
|
16
|
+
from sm_billing.models import Plan, Subscription
|
|
17
|
+
from sm_billing.plans import PlanService
|
|
18
|
+
from sm_billing.providers.factory import install_provider
|
|
19
|
+
from sm_billing.resolve import subscription_for
|
|
20
|
+
from sm_billing.schemas import (
|
|
21
|
+
ConnectionIn,
|
|
22
|
+
ConnectionOut,
|
|
23
|
+
PlanIn,
|
|
24
|
+
SubscriptionRow,
|
|
25
|
+
normalise_return_url,
|
|
26
|
+
)
|
|
27
|
+
from sm_billing.services import current_provider
|
|
28
|
+
from sm_billing.sync import resync as resync_subscription
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from fastapi import FastAPI
|
|
32
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
33
|
+
|
|
34
|
+
from sm_billing.schemas import AssignIn
|
|
35
|
+
|
|
36
|
+
_CLEAR_FLAGS = {
|
|
37
|
+
"clear_secret_key": "stripe_secret_key",
|
|
38
|
+
"clear_webhook_secret": "stripe_webhook_secret",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _services(app: FastAPI):
|
|
43
|
+
return getattr(app.state, c.PACKAGE)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ── plans ───────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
async def verify_prices(app: FastAPI, data: PlanIn, existing: Plan | None = None) -> None:
|
|
50
|
+
"""Under Stripe, each price must exist, be active, and match currency + interval.
|
|
51
|
+
|
|
52
|
+
A price the plan already carries is not re-checked: Stripe prices get
|
|
53
|
+
archived over time, and that must not lock the plan against unrelated
|
|
54
|
+
edits (renaming, hiding, limits).
|
|
55
|
+
"""
|
|
56
|
+
provider = _services(app).provider
|
|
57
|
+
if provider is None or not provider.supports_checkout:
|
|
58
|
+
return
|
|
59
|
+
for interval, price_id, current in (
|
|
60
|
+
(
|
|
61
|
+
c.Interval.MONTH,
|
|
62
|
+
data.stripe_price_month,
|
|
63
|
+
existing.stripe_price_month if existing else None,
|
|
64
|
+
),
|
|
65
|
+
(c.Interval.YEAR, data.stripe_price_year, existing.stripe_price_year if existing else None),
|
|
66
|
+
):
|
|
67
|
+
if not price_id or (
|
|
68
|
+
price_id == current and existing and existing.currency == data.currency
|
|
69
|
+
):
|
|
70
|
+
continue
|
|
71
|
+
try:
|
|
72
|
+
info = await provider.verify_price(price_id)
|
|
73
|
+
except ProviderError as exc:
|
|
74
|
+
raise BillingError("price_not_found", price=price_id, reason=str(exc)) from exc
|
|
75
|
+
reason = ""
|
|
76
|
+
if not info.active:
|
|
77
|
+
reason = "price is archived in Stripe"
|
|
78
|
+
elif info.currency != data.currency:
|
|
79
|
+
reason = f"currency {info.currency} ≠ plan currency {data.currency}"
|
|
80
|
+
elif info.interval != interval:
|
|
81
|
+
reason = f"interval {info.interval} ≠ {interval}"
|
|
82
|
+
if reason:
|
|
83
|
+
raise BillingError("price_mismatch", price=price_id, reason=reason)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
async def save_plan(db: AsyncSession, app: FastAPI, data: PlanIn, plan_id: int | None) -> Plan:
|
|
87
|
+
plans = PlanService(db)
|
|
88
|
+
await verify_prices(app, data, await plans.get(plan_id) if plan_id is not None else None)
|
|
89
|
+
return await plans.create(data) if plan_id is None else await plans.update(plan_id, data)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# ── subscriptions ───────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
async def subscription_rows(
|
|
96
|
+
db: AsyncSession,
|
|
97
|
+
app: FastAPI,
|
|
98
|
+
*,
|
|
99
|
+
status: str | None = None,
|
|
100
|
+
tenant_id: str | None = None,
|
|
101
|
+
limit: int = 200,
|
|
102
|
+
offset: int = 0,
|
|
103
|
+
) -> list[SubscriptionRow]:
|
|
104
|
+
default = await PlanService(db).default()
|
|
105
|
+
stmt = (
|
|
106
|
+
select(Tenant, Subscription, Plan)
|
|
107
|
+
.outerjoin(Subscription, Subscription.tenant_id == Tenant.id)
|
|
108
|
+
.outerjoin(Plan, Plan.id == Subscription.plan_id)
|
|
109
|
+
.order_by(Tenant.name, Tenant.id)
|
|
110
|
+
.limit(limit)
|
|
111
|
+
.offset(offset)
|
|
112
|
+
)
|
|
113
|
+
if status:
|
|
114
|
+
stmt = stmt.where(Subscription.status == status)
|
|
115
|
+
if tenant_id:
|
|
116
|
+
stmt = stmt.where(Tenant.id == tenant_id)
|
|
117
|
+
found = (await db.execute(stmt)).all()
|
|
118
|
+
counts = await tenant_service(db, app).member_counts([t.id for t, _, _ in found])
|
|
119
|
+
rows = []
|
|
120
|
+
for tenant, sub, plan in found:
|
|
121
|
+
# The subscribed plan even when lapsed — the status column says so.
|
|
122
|
+
shown = plan if plan is not None else default
|
|
123
|
+
rows.append(
|
|
124
|
+
SubscriptionRow(
|
|
125
|
+
tenant_id=tenant.id,
|
|
126
|
+
tenant_name=tenant.name,
|
|
127
|
+
tenant_slug=tenant.slug,
|
|
128
|
+
tenant_status=tenant.status,
|
|
129
|
+
members=counts.get(tenant.id, 0),
|
|
130
|
+
plan_id=shown.id,
|
|
131
|
+
plan_key=shown.key,
|
|
132
|
+
plan_name=shown.name,
|
|
133
|
+
status=sub.status if sub else None,
|
|
134
|
+
interval=sub.interval if sub else None,
|
|
135
|
+
quantity=sub.quantity if sub else None,
|
|
136
|
+
current_period_end=sub.current_period_end if sub else None,
|
|
137
|
+
cancel_at_period_end=bool(sub and sub.cancel_at_period_end),
|
|
138
|
+
suspended_by_billing=bool(sub and sub.suspended_by_billing),
|
|
139
|
+
provider_subscription_id=sub.provider_subscription_id if sub else None,
|
|
140
|
+
synced_at=sub.synced_at if sub else None,
|
|
141
|
+
)
|
|
142
|
+
)
|
|
143
|
+
return rows
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
async def one_row(db: AsyncSession, app: FastAPI, tenant_id: str) -> SubscriptionRow:
|
|
147
|
+
rows = await subscription_rows(db, app, tenant_id=tenant_id, limit=1)
|
|
148
|
+
if not rows:
|
|
149
|
+
raise BillingError("tenant_not_found", 404)
|
|
150
|
+
return rows[0]
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
async def assign(db: AsyncSession, app: FastAPI, tenant_id: str, data: AssignIn) -> None:
|
|
154
|
+
"""Manual provider only: set the tenant's plan and status directly."""
|
|
155
|
+
if current_provider(app).supports_checkout:
|
|
156
|
+
raise BillingError("provider_managed", 409)
|
|
157
|
+
if await db.get(Tenant, tenant_id) is None:
|
|
158
|
+
raise BillingError("tenant_not_found", 404)
|
|
159
|
+
plan = await PlanService(db).get(data.plan_id)
|
|
160
|
+
if plan is None:
|
|
161
|
+
raise BillingError("plan_not_found", 404)
|
|
162
|
+
sub = await subscription_for(db, tenant_id)
|
|
163
|
+
if sub is None:
|
|
164
|
+
sub = Subscription(tenant_id=tenant_id, plan_id=plan.id)
|
|
165
|
+
db.add(sub)
|
|
166
|
+
sub.plan_id = plan.id
|
|
167
|
+
sub.status = data.status
|
|
168
|
+
sub.interval = data.interval
|
|
169
|
+
sub.provider_subscription_id = None
|
|
170
|
+
sub.cancel_at_period_end = False
|
|
171
|
+
await db.flush()
|
|
172
|
+
await apply_lifecycle(db, app, sub)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
async def resync(db: AsyncSession, app: FastAPI, tenant_id: str) -> None:
|
|
176
|
+
provider = current_provider(app)
|
|
177
|
+
if not provider.supports_checkout:
|
|
178
|
+
raise BillingError("checkout_unavailable", 409)
|
|
179
|
+
sub = await subscription_for(db, tenant_id)
|
|
180
|
+
if sub is None or sub.provider_subscription_id is None:
|
|
181
|
+
raise BillingError("no_provider_subscription", 409)
|
|
182
|
+
await resync_subscription(db, app, provider, sub.provider_subscription_id)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# ── connection ──────────────────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def connection_out(app: FastAPI) -> ConnectionOut:
|
|
189
|
+
services = _services(app)
|
|
190
|
+
s = services.settings
|
|
191
|
+
return ConnectionOut(
|
|
192
|
+
provider=s.provider,
|
|
193
|
+
active_provider=services.provider.name if services.provider else "",
|
|
194
|
+
provider_error=services.provider_error,
|
|
195
|
+
has_secret_key=bool(s.stripe_secret_key),
|
|
196
|
+
has_webhook_secret=bool(s.stripe_webhook_secret),
|
|
197
|
+
return_base_url=s.return_base_url,
|
|
198
|
+
webhook_path=c.WEBHOOK_PATH,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def connection_changes(data: ConnectionIn) -> dict[str, Any]:
|
|
203
|
+
"""Blank secret = keep; a value is stripped and encrypted; ``clear_*`` wins."""
|
|
204
|
+
try:
|
|
205
|
+
return_base_url = normalise_return_url(data.return_base_url)
|
|
206
|
+
except ValueError as exc:
|
|
207
|
+
raise BillingError("invalid_return_url", message=str(exc)) from exc
|
|
208
|
+
changes: dict[str, Any] = {"return_base_url": return_base_url}
|
|
209
|
+
for field in c.SECRET_FIELDS:
|
|
210
|
+
value = getattr(data, field).strip()
|
|
211
|
+
if value:
|
|
212
|
+
changes[field] = crypto.encrypt_value(value)
|
|
213
|
+
for flag, field in _CLEAR_FLAGS.items():
|
|
214
|
+
if getattr(data, flag):
|
|
215
|
+
changes[field] = ""
|
|
216
|
+
return changes
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
async def save_connection(db: AsyncSession, app: FastAPI, data: ConnectionIn) -> ConnectionOut:
|
|
220
|
+
from settings.reload import apply_changes_and_reload
|
|
221
|
+
from settings.service import SettingService
|
|
222
|
+
from settings.store import SettingsStore
|
|
223
|
+
|
|
224
|
+
store = SettingsStore(SettingService(db))
|
|
225
|
+
await apply_changes_and_reload(
|
|
226
|
+
app, app.state.sm.event_bus, store, package=c.PACKAGE, changes=connection_changes(data)
|
|
227
|
+
)
|
|
228
|
+
# New secrets take effect now; switching provider itself still needs a
|
|
229
|
+
# restart. A stripe that fell back to manual at startup (secrets missing)
|
|
230
|
+
# is still the chosen provider, so its new secrets apply too.
|
|
231
|
+
services = _services(app)
|
|
232
|
+
running = services.provider.name if services.provider else ""
|
|
233
|
+
chosen = services.settings.provider
|
|
234
|
+
if chosen == running or (chosen == c.PROVIDER_STRIPE and services.provider_error):
|
|
235
|
+
install_provider(services)
|
|
236
|
+
return connection_out(app)
|