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.
Files changed (64) hide show
  1. simple_module_billing-0.0.8.dist-info/METADATA +211 -0
  2. simple_module_billing-0.0.8.dist-info/RECORD +64 -0
  3. simple_module_billing-0.0.8.dist-info/WHEEL +4 -0
  4. simple_module_billing-0.0.8.dist-info/entry_points.txt +5 -0
  5. sm_billing/__init__.py +0 -0
  6. sm_billing/admin.py +236 -0
  7. sm_billing/checkout.py +203 -0
  8. sm_billing/cli.py +44 -0
  9. sm_billing/components/AdminNav.tsx +44 -0
  10. sm_billing/components/AssignDialog.tsx +87 -0
  11. sm_billing/components/CurrentPlanCard.tsx +86 -0
  12. sm_billing/components/LimitRows.tsx +80 -0
  13. sm_billing/components/PlanCard.tsx +82 -0
  14. sm_billing/components/PlanEditor.tsx +213 -0
  15. sm_billing/components/PlansTable.tsx +78 -0
  16. sm_billing/components/StatusBadge.tsx +7 -0
  17. sm_billing/components/SubscriptionsTable.tsx +86 -0
  18. sm_billing/constants.py +112 -0
  19. sm_billing/context.py +68 -0
  20. sm_billing/contracts/__init__.py +0 -0
  21. sm_billing/contracts/provider.py +107 -0
  22. sm_billing/crypto.py +89 -0
  23. sm_billing/deps.py +67 -0
  24. sm_billing/endpoints/__init__.py +0 -0
  25. sm_billing/endpoints/admin_api.py +93 -0
  26. sm_billing/endpoints/admin_views.py +70 -0
  27. sm_billing/endpoints/api.py +61 -0
  28. sm_billing/endpoints/routers.py +26 -0
  29. sm_billing/endpoints/views.py +56 -0
  30. sm_billing/endpoints/webhooks.py +27 -0
  31. sm_billing/entitlements.py +30 -0
  32. sm_billing/errors.py +41 -0
  33. sm_billing/hooks/useCheckoutPolling.ts +50 -0
  34. sm_billing/lifecycle.py +74 -0
  35. sm_billing/models.py +112 -0
  36. sm_billing/module.py +132 -0
  37. sm_billing/package.json +17 -0
  38. sm_billing/pages/Billing.tsx +201 -0
  39. sm_billing/pages/Connection.tsx +219 -0
  40. sm_billing/pages/Plans.tsx +122 -0
  41. sm_billing/pages/Subscriptions.tsx +123 -0
  42. sm_billing/plans.py +227 -0
  43. sm_billing/providers/__init__.py +0 -0
  44. sm_billing/providers/factory.py +47 -0
  45. sm_billing/providers/manual.py +60 -0
  46. sm_billing/providers/stripe.py +209 -0
  47. sm_billing/reconcile.py +71 -0
  48. sm_billing/resolve.py +30 -0
  49. sm_billing/schemas.py +222 -0
  50. sm_billing/seats.py +63 -0
  51. sm_billing/services.py +36 -0
  52. sm_billing/settings.py +47 -0
  53. sm_billing/startup.py +27 -0
  54. sm_billing/sync.py +120 -0
  55. sm_billing/utils/api.test.ts +57 -0
  56. sm_billing/utils/api.ts +95 -0
  57. sm_billing/utils/format.test.ts +125 -0
  58. sm_billing/utils/format.ts +91 -0
  59. sm_billing/utils/plan-form.test.ts +180 -0
  60. sm_billing/utils/plan-form.ts +204 -0
  61. sm_billing/utils/seats.test.ts +22 -0
  62. sm_billing/utils/seats.ts +5 -0
  63. sm_billing/utils/types.ts +87 -0
  64. 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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,5 @@
1
+ [simple_module]
2
+ billing = sm_billing.module:BillingModule
3
+
4
+ [simple_module_cli.cli_plugins]
5
+ billing = sm_billing.cli:app
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)