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,47 @@
|
|
|
1
|
+
"""Build the configured provider from the hydrated settings.
|
|
2
|
+
|
|
3
|
+
A misconfigured ``stripe`` (missing or undecryptable secrets) falls back to
|
|
4
|
+
``manual`` with the reason kept on ``BillingServices.provider_error``: the app
|
|
5
|
+
still boots, paid checkout stays off, and the admin screens say why.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
|
|
12
|
+
from sm_billing import constants as c
|
|
13
|
+
from sm_billing import crypto
|
|
14
|
+
from sm_billing.contracts.provider import BillingProvider
|
|
15
|
+
from sm_billing.providers.manual import ManualProvider
|
|
16
|
+
from sm_billing.services import BillingServices
|
|
17
|
+
from sm_billing.settings import BillingSettings
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def build_provider(settings: BillingSettings) -> tuple[BillingProvider, str]:
|
|
23
|
+
if settings.provider != c.PROVIDER_STRIPE:
|
|
24
|
+
return ManualProvider(), ""
|
|
25
|
+
try:
|
|
26
|
+
secret = crypto.decrypt_value(settings.stripe_secret_key, "stripe_secret_key")
|
|
27
|
+
webhook = crypto.decrypt_value(settings.stripe_webhook_secret, "stripe_webhook_secret")
|
|
28
|
+
except crypto.BillingKeyUnreadableError as exc:
|
|
29
|
+
error = str(exc)
|
|
30
|
+
else:
|
|
31
|
+
missing = [
|
|
32
|
+
name
|
|
33
|
+
for name, value in (("stripe_secret_key", secret), ("stripe_webhook_secret", webhook))
|
|
34
|
+
if not value
|
|
35
|
+
]
|
|
36
|
+
if not missing:
|
|
37
|
+
from sm_billing.providers.stripe import StripeProvider
|
|
38
|
+
|
|
39
|
+
return StripeProvider(secret_key=secret, webhook_secret=webhook), ""
|
|
40
|
+
error = f"provider is 'stripe' but {', '.join(missing)} is not set"
|
|
41
|
+
logger.error("billing: %s — falling back to the manual provider", error)
|
|
42
|
+
return ManualProvider(), error
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def install_provider(services: BillingServices) -> None:
|
|
46
|
+
"""Set ``services.provider`` (and ``provider_error`` when it falls back)."""
|
|
47
|
+
services.provider, services.provider_error = build_provider(services.settings)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""``manual`` — no payment provider at all.
|
|
2
|
+
|
|
3
|
+
Admins assign a plan and status on ``/admin/billing/subscriptions``, which
|
|
4
|
+
writes the subscription row directly. Everything that would reach a payment
|
|
5
|
+
provider refuses with ``ProviderError``; the API layer checks
|
|
6
|
+
``supports_checkout`` first so tenants see "not available", not an error.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Mapping
|
|
12
|
+
|
|
13
|
+
from sm_billing.constants import PROVIDER_MANUAL
|
|
14
|
+
from sm_billing.contracts.provider import (
|
|
15
|
+
CheckoutSession,
|
|
16
|
+
PriceInfo,
|
|
17
|
+
ProviderError,
|
|
18
|
+
SubscriptionSnapshot,
|
|
19
|
+
WebhookEvent,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
_NO = "The manual billing provider has no payment integration."
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ManualProvider:
|
|
26
|
+
name = PROVIDER_MANUAL
|
|
27
|
+
supports_checkout = False
|
|
28
|
+
|
|
29
|
+
async def ensure_customer(self, tenant_id: str, name: str, email: str | None) -> str | None:
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
async def create_checkout(self, **_: object) -> CheckoutSession:
|
|
33
|
+
raise ProviderError(_NO)
|
|
34
|
+
|
|
35
|
+
async def expire_checkout(self, session_id: str) -> None:
|
|
36
|
+
raise ProviderError(_NO)
|
|
37
|
+
|
|
38
|
+
async def live_subscription_ids(self, customer_id: str) -> list[str]:
|
|
39
|
+
return []
|
|
40
|
+
|
|
41
|
+
async def portal_url(self, customer_id: str, return_url: str) -> str:
|
|
42
|
+
raise ProviderError(_NO)
|
|
43
|
+
|
|
44
|
+
async def change_plan(self, subscription_id: str, price_id: str, quantity: int) -> None:
|
|
45
|
+
raise ProviderError(_NO)
|
|
46
|
+
|
|
47
|
+
async def set_quantity(self, subscription_id: str, quantity: int) -> None:
|
|
48
|
+
raise ProviderError(_NO)
|
|
49
|
+
|
|
50
|
+
async def cancel_at_period_end(self, subscription_id: str) -> None:
|
|
51
|
+
raise ProviderError(_NO)
|
|
52
|
+
|
|
53
|
+
def parse_webhook(self, body: bytes, headers: Mapping[str, str]) -> WebhookEvent:
|
|
54
|
+
raise ProviderError(_NO)
|
|
55
|
+
|
|
56
|
+
async def fetch_subscription(self, subscription_id: str) -> SubscriptionSnapshot:
|
|
57
|
+
raise ProviderError(_NO)
|
|
58
|
+
|
|
59
|
+
async def verify_price(self, price_id: str) -> PriceInfo:
|
|
60
|
+
raise ProviderError(_NO)
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""``StripeProvider`` — the ``BillingProvider`` over the official ``stripe`` SDK.
|
|
2
|
+
|
|
3
|
+
Every response is turned into a plain dict (``to_dict``) at the boundary and
|
|
4
|
+
mapped into the protocol's dataclasses, so nothing Stripe-shaped leaks past
|
|
5
|
+
this file. SDK errors become ``ProviderError``; a bad webhook signature or
|
|
6
|
+
body becomes ``InvalidWebhookSignature``.
|
|
7
|
+
|
|
8
|
+
Period end is read from the subscription item when the subscription itself
|
|
9
|
+
lacks it — Stripe API versions from 2025 onward moved it there.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from collections.abc import Mapping
|
|
15
|
+
from datetime import UTC, datetime
|
|
16
|
+
from hashlib import sha256
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
import stripe
|
|
20
|
+
|
|
21
|
+
from sm_billing.constants import PROVIDER_STRIPE
|
|
22
|
+
from sm_billing.contracts.provider import (
|
|
23
|
+
CheckoutSession,
|
|
24
|
+
InvalidWebhookSignature,
|
|
25
|
+
PriceInfo,
|
|
26
|
+
ProviderError,
|
|
27
|
+
SubscriptionSnapshot,
|
|
28
|
+
WebhookEvent,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
_SUBSCRIPTION_EVENTS = ("customer.subscription.",)
|
|
32
|
+
_CHECKOUT_COMPLETED = "checkout.session.completed"
|
|
33
|
+
# Still billing or granting access — a second checkout would double-charge.
|
|
34
|
+
_LIVE_STATUSES = frozenset({"active", "trialing", "past_due", "unpaid"})
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _ts(value: Any) -> datetime | None:
|
|
38
|
+
return datetime.fromtimestamp(int(value), UTC) if value else None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _id(value: Any) -> str | None:
|
|
42
|
+
"""A Stripe reference: an id string, or an expanded object carrying ``id``."""
|
|
43
|
+
if isinstance(value, dict):
|
|
44
|
+
return value.get("id")
|
|
45
|
+
return value or None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _first_item(sub: dict) -> dict:
|
|
49
|
+
items = (sub.get("items") or {}).get("data") or []
|
|
50
|
+
if not items:
|
|
51
|
+
raise ProviderError(f"subscription {sub.get('id')} has no items")
|
|
52
|
+
return items[0]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def snapshot_from(sub: dict) -> SubscriptionSnapshot:
|
|
56
|
+
item = _first_item(sub)
|
|
57
|
+
return SubscriptionSnapshot(
|
|
58
|
+
id=sub["id"],
|
|
59
|
+
customer_id=_id(sub.get("customer")),
|
|
60
|
+
tenant_id=(sub.get("metadata") or {}).get("tenant_id"),
|
|
61
|
+
status=sub.get("status", ""),
|
|
62
|
+
price_id=_id(item.get("price")),
|
|
63
|
+
quantity=int(item.get("quantity") or 1),
|
|
64
|
+
trial_end=_ts(sub.get("trial_end")),
|
|
65
|
+
current_period_end=_ts(sub.get("current_period_end") or item.get("current_period_end")),
|
|
66
|
+
cancel_at_period_end=bool(sub.get("cancel_at_period_end")),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class StripeProvider:
|
|
71
|
+
name = PROVIDER_STRIPE
|
|
72
|
+
supports_checkout = True
|
|
73
|
+
|
|
74
|
+
def __init__(self, *, secret_key: str, webhook_secret: str) -> None:
|
|
75
|
+
self._client = stripe.StripeClient(secret_key, max_network_retries=2)
|
|
76
|
+
self._v1 = self._client.v1
|
|
77
|
+
self._webhook_secret = webhook_secret
|
|
78
|
+
|
|
79
|
+
async def _call(
|
|
80
|
+
self, fn, *args: Any, params: dict | None = None, options: dict | None = None
|
|
81
|
+
) -> dict:
|
|
82
|
+
kwargs = {k: v for k, v in (("params", params), ("options", options)) if v is not None}
|
|
83
|
+
try:
|
|
84
|
+
result = await fn(*args, **kwargs)
|
|
85
|
+
except stripe.StripeError as exc:
|
|
86
|
+
raise ProviderError(exc.user_message or str(exc)) from exc
|
|
87
|
+
return result.to_dict()
|
|
88
|
+
|
|
89
|
+
async def ensure_customer(self, tenant_id: str, name: str, email: str | None) -> str | None:
|
|
90
|
+
params: dict[str, Any] = {"name": name, "metadata": {"tenant_id": tenant_id}}
|
|
91
|
+
if email:
|
|
92
|
+
params["email"] = email
|
|
93
|
+
# Keyed on the tenant and the params: the customer row is rolled back
|
|
94
|
+
# with a failed checkout, and the retry must get the same Stripe
|
|
95
|
+
# customer back (Stripe keeps idempotency keys for 24h). The params are
|
|
96
|
+
# part of the key because Stripe rejects a reused key with different
|
|
97
|
+
# params, which would block checkout for a day after a rename or when
|
|
98
|
+
# another owner retries.
|
|
99
|
+
digest = sha256(f"{name}\0{email or ''}".encode()).hexdigest()[:16]
|
|
100
|
+
options = {"idempotency_key": f"sm-billing-customer-{tenant_id}-{digest}"}
|
|
101
|
+
customer = await self._call(self._v1.customers.create_async, params=params, options=options)
|
|
102
|
+
return customer["id"]
|
|
103
|
+
|
|
104
|
+
async def create_checkout(
|
|
105
|
+
self,
|
|
106
|
+
*,
|
|
107
|
+
customer_id: str | None,
|
|
108
|
+
price_id: str,
|
|
109
|
+
quantity: int,
|
|
110
|
+
trial_days: int,
|
|
111
|
+
tenant_id: str,
|
|
112
|
+
success_url: str,
|
|
113
|
+
cancel_url: str,
|
|
114
|
+
) -> CheckoutSession:
|
|
115
|
+
subscription_data: dict[str, Any] = {"metadata": {"tenant_id": tenant_id}}
|
|
116
|
+
if trial_days > 0:
|
|
117
|
+
subscription_data["trial_period_days"] = trial_days
|
|
118
|
+
params: dict[str, Any] = {
|
|
119
|
+
"mode": "subscription",
|
|
120
|
+
"line_items": [{"price": price_id, "quantity": quantity}],
|
|
121
|
+
"client_reference_id": tenant_id,
|
|
122
|
+
"subscription_data": subscription_data,
|
|
123
|
+
"success_url": success_url,
|
|
124
|
+
"cancel_url": cancel_url,
|
|
125
|
+
}
|
|
126
|
+
if customer_id:
|
|
127
|
+
params["customer"] = customer_id
|
|
128
|
+
session = await self._call(self._v1.checkout.sessions.create_async, params=params)
|
|
129
|
+
return CheckoutSession(id=session["id"], url=session["url"])
|
|
130
|
+
|
|
131
|
+
async def expire_checkout(self, session_id: str) -> None:
|
|
132
|
+
await self._call(self._v1.checkout.sessions.expire_async, session_id)
|
|
133
|
+
|
|
134
|
+
async def live_subscription_ids(self, customer_id: str) -> list[str]:
|
|
135
|
+
params = {"customer": customer_id, "status": "all", "limit": 100}
|
|
136
|
+
listing = await self._call(self._v1.subscriptions.list_async, params=params)
|
|
137
|
+
return [s["id"] for s in listing.get("data", []) if s.get("status") in _LIVE_STATUSES]
|
|
138
|
+
|
|
139
|
+
async def portal_url(self, customer_id: str, return_url: str) -> str:
|
|
140
|
+
params = {"customer": customer_id, "return_url": return_url}
|
|
141
|
+
session = await self._call(self._v1.billing_portal.sessions.create_async, params=params)
|
|
142
|
+
return session["url"]
|
|
143
|
+
|
|
144
|
+
async def _item_id(self, subscription_id: str) -> str:
|
|
145
|
+
sub = await self._call(self._v1.subscriptions.retrieve_async, subscription_id)
|
|
146
|
+
return _first_item(sub)["id"]
|
|
147
|
+
|
|
148
|
+
async def change_plan(self, subscription_id: str, price_id: str, quantity: int) -> None:
|
|
149
|
+
item = await self._item_id(subscription_id)
|
|
150
|
+
params = {
|
|
151
|
+
"items": [{"id": item, "price": price_id, "quantity": quantity}],
|
|
152
|
+
"proration_behavior": "create_prorations",
|
|
153
|
+
"cancel_at_period_end": False,
|
|
154
|
+
}
|
|
155
|
+
await self._call(self._v1.subscriptions.update_async, subscription_id, params=params)
|
|
156
|
+
|
|
157
|
+
async def set_quantity(self, subscription_id: str, quantity: int) -> None:
|
|
158
|
+
item = await self._item_id(subscription_id)
|
|
159
|
+
params = {
|
|
160
|
+
"items": [{"id": item, "quantity": quantity}],
|
|
161
|
+
"proration_behavior": "create_prorations",
|
|
162
|
+
}
|
|
163
|
+
await self._call(self._v1.subscriptions.update_async, subscription_id, params=params)
|
|
164
|
+
|
|
165
|
+
async def cancel_at_period_end(self, subscription_id: str) -> None:
|
|
166
|
+
params = {"cancel_at_period_end": True}
|
|
167
|
+
await self._call(self._v1.subscriptions.update_async, subscription_id, params=params)
|
|
168
|
+
|
|
169
|
+
def parse_webhook(self, body: bytes, headers: Mapping[str, str]) -> WebhookEvent:
|
|
170
|
+
signature = headers.get("stripe-signature")
|
|
171
|
+
if not signature:
|
|
172
|
+
raise InvalidWebhookSignature("missing Stripe-Signature header")
|
|
173
|
+
try:
|
|
174
|
+
event = self._client.construct_event(body, signature, self._webhook_secret).to_dict()
|
|
175
|
+
except (stripe.SignatureVerificationError, ValueError) as exc:
|
|
176
|
+
raise InvalidWebhookSignature(str(exc)) from exc
|
|
177
|
+
obj = (event.get("data") or {}).get("object") or {}
|
|
178
|
+
etype = event.get("type", "")
|
|
179
|
+
if etype == _CHECKOUT_COMPLETED:
|
|
180
|
+
return WebhookEvent(
|
|
181
|
+
id=event["id"],
|
|
182
|
+
type=etype,
|
|
183
|
+
subscription_id=_id(obj.get("subscription")),
|
|
184
|
+
tenant_id=obj.get("client_reference_id"),
|
|
185
|
+
customer_id=_id(obj.get("customer")),
|
|
186
|
+
)
|
|
187
|
+
if etype.startswith(_SUBSCRIPTION_EVENTS):
|
|
188
|
+
return WebhookEvent(
|
|
189
|
+
id=event["id"],
|
|
190
|
+
type=etype,
|
|
191
|
+
subscription_id=obj.get("id"),
|
|
192
|
+
tenant_id=(obj.get("metadata") or {}).get("tenant_id"),
|
|
193
|
+
customer_id=_id(obj.get("customer")),
|
|
194
|
+
)
|
|
195
|
+
return WebhookEvent(id=event["id"], type=etype)
|
|
196
|
+
|
|
197
|
+
async def fetch_subscription(self, subscription_id: str) -> SubscriptionSnapshot:
|
|
198
|
+
sub = await self._call(self._v1.subscriptions.retrieve_async, subscription_id)
|
|
199
|
+
return snapshot_from(sub)
|
|
200
|
+
|
|
201
|
+
async def verify_price(self, price_id: str) -> PriceInfo:
|
|
202
|
+
price = await self._call(self._v1.prices.retrieve_async, price_id)
|
|
203
|
+
recurring = price.get("recurring") or {}
|
|
204
|
+
return PriceInfo(
|
|
205
|
+
id=price["id"],
|
|
206
|
+
currency=str(price.get("currency", "")).lower(),
|
|
207
|
+
interval=recurring.get("interval"),
|
|
208
|
+
active=bool(price.get("active")),
|
|
209
|
+
)
|
sm_billing/reconcile.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Re-pull every provider subscription; the safety net for missed webhooks.
|
|
2
|
+
|
|
3
|
+
Safe to run live and to run twice: each tenant is one fetch → ``apply_snapshot``
|
|
4
|
+
→ quantity push, the same path a webhook takes. A failure for one tenant is
|
|
5
|
+
recorded and the run moves on.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
from simple_module_db import finalize_session
|
|
15
|
+
from sqlalchemy import select
|
|
16
|
+
|
|
17
|
+
from sm_billing import constants as c
|
|
18
|
+
from sm_billing.models import Subscription
|
|
19
|
+
from sm_billing.seats import push_quantity
|
|
20
|
+
from sm_billing.services import current_provider
|
|
21
|
+
from sm_billing.sync import resync
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from fastapi import FastAPI
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class ReconcileReport:
|
|
31
|
+
synced: int = 0
|
|
32
|
+
pushed: int = 0
|
|
33
|
+
errors: list[tuple[str, str]] = field(default_factory=list)
|
|
34
|
+
skipped: str = ""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
async def _targets(app: FastAPI, tenant_id: str | None) -> list[tuple[str, str]]:
|
|
38
|
+
async with app.state.sm.db.session_factory() as session:
|
|
39
|
+
stmt = select(Subscription.tenant_id, Subscription.provider_subscription_id).where(
|
|
40
|
+
Subscription.provider_subscription_id.is_not(None)
|
|
41
|
+
)
|
|
42
|
+
if tenant_id is not None:
|
|
43
|
+
stmt = stmt.where(Subscription.tenant_id == tenant_id)
|
|
44
|
+
return [(t, s) for t, s in (await session.execute(stmt)).all()]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
async def reconcile_one(app: FastAPI, tenant_id: str, subscription_id: str) -> bool:
|
|
48
|
+
"""Sync one tenant from the provider; returns whether a quantity was pushed."""
|
|
49
|
+
provider = current_provider(app)
|
|
50
|
+
async with app.state.sm.db.session_factory() as session:
|
|
51
|
+
await resync(session, app, provider, subscription_id)
|
|
52
|
+
await finalize_session(session)
|
|
53
|
+
return await push_quantity(app, tenant_id)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
async def reconcile(app: FastAPI, *, tenant_id: str | None = None) -> ReconcileReport:
|
|
57
|
+
report = ReconcileReport()
|
|
58
|
+
provider = getattr(app.state, c.PACKAGE).provider
|
|
59
|
+
if provider is None or not provider.supports_checkout:
|
|
60
|
+
report.skipped = f"{getattr(provider, 'name', 'no')} provider"
|
|
61
|
+
return report
|
|
62
|
+
for target_tenant, subscription_id in await _targets(app, tenant_id):
|
|
63
|
+
try:
|
|
64
|
+
pushed = await reconcile_one(app, target_tenant, subscription_id)
|
|
65
|
+
except Exception as exc:
|
|
66
|
+
logger.warning("billing: reconcile failed for tenant %s", target_tenant, exc_info=True)
|
|
67
|
+
report.errors.append((target_tenant, f"{type(exc).__name__}: {exc}"))
|
|
68
|
+
continue
|
|
69
|
+
report.synced += 1
|
|
70
|
+
report.pushed += int(pushed)
|
|
71
|
+
return report
|
sm_billing/resolve.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Which plan is in force for a tenant.
|
|
2
|
+
|
|
3
|
+
A subscription whose status is trialing, active or past_due grants its plan;
|
|
4
|
+
anything else — no row, canceled, incomplete, unpaid — falls back to the
|
|
5
|
+
default plan. One indexed lookup plus the default; no cache, because a
|
|
6
|
+
per-process cache would go stale across workers.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from sqlalchemy import select
|
|
12
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
13
|
+
|
|
14
|
+
from sm_billing.constants import ENTITLED_STATUSES
|
|
15
|
+
from sm_billing.models import Plan, Subscription
|
|
16
|
+
from sm_billing.plans import PlanService
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
async def subscription_for(db: AsyncSession, tenant_id: str) -> Subscription | None:
|
|
20
|
+
stmt = select(Subscription).where(Subscription.tenant_id == tenant_id)
|
|
21
|
+
return (await db.execute(stmt)).scalar_one_or_none()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
async def effective_plan(db: AsyncSession, tenant_id: str) -> tuple[Plan, Subscription | None]:
|
|
25
|
+
sub = await subscription_for(db, tenant_id)
|
|
26
|
+
if sub is not None and sub.status in ENTITLED_STATUSES:
|
|
27
|
+
plan = await db.get(Plan, sub.plan_id)
|
|
28
|
+
if plan is not None:
|
|
29
|
+
return plan, sub
|
|
30
|
+
return await PlanService(db).default(), sub
|
sm_billing/schemas.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Pydantic request/response models for the billing API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from urllib.parse import urlsplit
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, Field, field_validator
|
|
10
|
+
|
|
11
|
+
from sm_billing.constants import Interval, PricingModel, SubscriptionStatus
|
|
12
|
+
|
|
13
|
+
_KEY_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$")
|
|
14
|
+
_LIMIT_KEY_RE = re.compile(r"^[a-z0-9][a-z0-9_.-]{0,99}$")
|
|
15
|
+
# Stored in int4 columns (amounts, limits); keep well inside 2**31 - 1.
|
|
16
|
+
MAX_AMOUNT = 2_000_000_000
|
|
17
|
+
MAX_SORT_ORDER = 1_000_000
|
|
18
|
+
RETURN_URL_MAX = 255
|
|
19
|
+
RETURN_URL_RULE = (
|
|
20
|
+
"Return URL must be an http(s) origin such as https://app.example.com — "
|
|
21
|
+
f"no path, query or fragment, at most {RETURN_URL_MAX} characters."
|
|
22
|
+
)
|
|
23
|
+
_HOST_RE = re.compile(r"^[A-Za-z0-9.-]+$|^\[[0-9A-Fa-f:.]+\]$")
|
|
24
|
+
_CONTROL_RE = re.compile(r"[\x00-\x20\x7f]")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _blank_to_none(value: str | None) -> str | None:
|
|
28
|
+
if value is None:
|
|
29
|
+
return None
|
|
30
|
+
value = value.strip()
|
|
31
|
+
return value or None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class PlanIn(BaseModel):
|
|
35
|
+
key: str
|
|
36
|
+
name: str = Field(min_length=1, max_length=120)
|
|
37
|
+
description: str = Field(default="", max_length=500)
|
|
38
|
+
pricing_model: PricingModel
|
|
39
|
+
currency: str = Field(default="eur", min_length=3, max_length=3)
|
|
40
|
+
amount_month: int | None = Field(default=None, ge=0, le=MAX_AMOUNT)
|
|
41
|
+
amount_year: int | None = Field(default=None, ge=0, le=MAX_AMOUNT)
|
|
42
|
+
stripe_price_month: str | None = Field(default=None, max_length=255)
|
|
43
|
+
stripe_price_year: str | None = Field(default=None, max_length=255)
|
|
44
|
+
trial_days: int = Field(default=0, ge=0, le=730)
|
|
45
|
+
limits: dict[str, int] = Field(default_factory=dict)
|
|
46
|
+
features: list[str] = Field(default_factory=list)
|
|
47
|
+
is_default: bool = False
|
|
48
|
+
is_public: bool = True
|
|
49
|
+
sort_order: int = Field(default=0, ge=-MAX_SORT_ORDER, le=MAX_SORT_ORDER)
|
|
50
|
+
|
|
51
|
+
@field_validator("key")
|
|
52
|
+
@classmethod
|
|
53
|
+
def _key(cls, value: str) -> str:
|
|
54
|
+
if not _KEY_RE.match(value):
|
|
55
|
+
raise ValueError("key must be 1-64 chars of a-z, 0-9, '-' or '_'")
|
|
56
|
+
return value
|
|
57
|
+
|
|
58
|
+
@field_validator("currency")
|
|
59
|
+
@classmethod
|
|
60
|
+
def _currency(cls, value: str) -> str:
|
|
61
|
+
return value.lower()
|
|
62
|
+
|
|
63
|
+
@field_validator("stripe_price_month", "stripe_price_year", mode="before")
|
|
64
|
+
@classmethod
|
|
65
|
+
def _prices(cls, value: str | None) -> str | None:
|
|
66
|
+
return _blank_to_none(value)
|
|
67
|
+
|
|
68
|
+
@field_validator("limits")
|
|
69
|
+
@classmethod
|
|
70
|
+
def _limits(cls, value: dict[str, int]) -> dict[str, int]:
|
|
71
|
+
for key, limit in value.items():
|
|
72
|
+
if not _LIMIT_KEY_RE.match(key):
|
|
73
|
+
raise ValueError(f"invalid limit key {key!r}")
|
|
74
|
+
if limit < 0:
|
|
75
|
+
raise ValueError(f"limit for {key!r} must be >= 0")
|
|
76
|
+
if limit > MAX_AMOUNT:
|
|
77
|
+
raise ValueError(f"limit for {key!r} must be <= {MAX_AMOUNT}")
|
|
78
|
+
return value
|
|
79
|
+
|
|
80
|
+
@field_validator("features")
|
|
81
|
+
@classmethod
|
|
82
|
+
def _features(cls, value: list[str]) -> list[str]:
|
|
83
|
+
cleaned = sorted({f.strip() for f in value if f.strip()})
|
|
84
|
+
for feature in cleaned:
|
|
85
|
+
if not _LIMIT_KEY_RE.match(feature):
|
|
86
|
+
raise ValueError(f"invalid feature key {feature!r}")
|
|
87
|
+
return cleaned
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class PlanOut(BaseModel):
|
|
91
|
+
"""A stored plan as the API returns it.
|
|
92
|
+
|
|
93
|
+
Deliberately not a ``PlanIn`` subclass: input bounds and validators guard
|
|
94
|
+
writes, and a row stored before a bound existed must still be readable —
|
|
95
|
+
otherwise one legacy value 500s every screen that lists plans.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
id: int
|
|
99
|
+
key: str
|
|
100
|
+
name: str
|
|
101
|
+
description: str
|
|
102
|
+
pricing_model: PricingModel
|
|
103
|
+
currency: str
|
|
104
|
+
amount_month: int | None
|
|
105
|
+
amount_year: int | None
|
|
106
|
+
stripe_price_month: str | None
|
|
107
|
+
stripe_price_year: str | None
|
|
108
|
+
trial_days: int
|
|
109
|
+
limits: dict[str, int]
|
|
110
|
+
features: list[str]
|
|
111
|
+
is_default: bool
|
|
112
|
+
is_public: bool
|
|
113
|
+
sort_order: int
|
|
114
|
+
archived_at: datetime | None = None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class SubscriptionOut(BaseModel):
|
|
118
|
+
status: SubscriptionStatus | None
|
|
119
|
+
interval: Interval | None
|
|
120
|
+
quantity: int
|
|
121
|
+
trial_end: datetime | None
|
|
122
|
+
current_period_end: datetime | None
|
|
123
|
+
cancel_at_period_end: bool
|
|
124
|
+
has_provider_subscription: bool
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class StatusOut(BaseModel):
|
|
128
|
+
plan: PlanOut
|
|
129
|
+
subscription: SubscriptionOut | None
|
|
130
|
+
seats: dict[str, int | None]
|
|
131
|
+
provider: str
|
|
132
|
+
checkout_available: bool
|
|
133
|
+
portal_available: bool
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class PlanChoice(BaseModel):
|
|
137
|
+
plan_id: int
|
|
138
|
+
interval: Interval = Interval.MONTH
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class UrlOut(BaseModel):
|
|
142
|
+
url: str
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class AssignIn(BaseModel):
|
|
146
|
+
plan_id: int
|
|
147
|
+
status: SubscriptionStatus = SubscriptionStatus.ACTIVE
|
|
148
|
+
interval: Interval | None = None
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class ConnectionIn(BaseModel):
|
|
152
|
+
"""Empty string = keep the stored value; ``clear_*`` wins over a value."""
|
|
153
|
+
|
|
154
|
+
stripe_secret_key: str = ""
|
|
155
|
+
stripe_webhook_secret: str = ""
|
|
156
|
+
clear_secret_key: bool = False
|
|
157
|
+
clear_webhook_secret: bool = False
|
|
158
|
+
# Validated by ``normalise_return_url`` so a bad value is a 400 with a
|
|
159
|
+
# readable message, not a raw 422.
|
|
160
|
+
return_base_url: str = ""
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def normalise_return_url(value: str) -> str:
|
|
164
|
+
"""Blank, or an http(s) origin without a trailing "/". ``ValueError`` otherwise."""
|
|
165
|
+
value = value.strip()
|
|
166
|
+
if not value:
|
|
167
|
+
return ""
|
|
168
|
+
# Whitespace/control characters first: urlsplit drops \t \n \r silently,
|
|
169
|
+
# so they would pass every check below and be stored verbatim.
|
|
170
|
+
if len(value) > RETURN_URL_MAX or _CONTROL_RE.search(value):
|
|
171
|
+
raise ValueError(RETURN_URL_RULE)
|
|
172
|
+
try:
|
|
173
|
+
parts = urlsplit(value)
|
|
174
|
+
port_ok = parts.port is None or parts.port > 0
|
|
175
|
+
except ValueError as exc:
|
|
176
|
+
raise ValueError(RETURN_URL_RULE) from exc
|
|
177
|
+
netloc_host = parts.netloc.rsplit(":", 1)[0] if parts.port else parts.netloc
|
|
178
|
+
if (
|
|
179
|
+
parts.scheme.lower() not in ("http", "https")
|
|
180
|
+
or not parts.hostname
|
|
181
|
+
or "@" in parts.netloc
|
|
182
|
+
or not _HOST_RE.match(netloc_host)
|
|
183
|
+
or not port_ok
|
|
184
|
+
or parts.path not in ("", "/")
|
|
185
|
+
or parts.query
|
|
186
|
+
or parts.fragment
|
|
187
|
+
or "?" in value
|
|
188
|
+
or "#" in value
|
|
189
|
+
):
|
|
190
|
+
raise ValueError(RETURN_URL_RULE)
|
|
191
|
+
return value.rstrip("/")
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class ConnectionOut(BaseModel):
|
|
195
|
+
provider: str
|
|
196
|
+
active_provider: str
|
|
197
|
+
provider_error: str
|
|
198
|
+
has_secret_key: bool
|
|
199
|
+
has_webhook_secret: bool
|
|
200
|
+
return_base_url: str
|
|
201
|
+
webhook_path: str
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class SubscriptionRow(BaseModel):
|
|
205
|
+
"""One tenant on the admin subscriptions screen (tenants without a row too)."""
|
|
206
|
+
|
|
207
|
+
tenant_id: str
|
|
208
|
+
tenant_name: str
|
|
209
|
+
tenant_slug: str
|
|
210
|
+
tenant_status: str
|
|
211
|
+
members: int
|
|
212
|
+
plan_id: int
|
|
213
|
+
plan_key: str
|
|
214
|
+
plan_name: str
|
|
215
|
+
status: SubscriptionStatus | None
|
|
216
|
+
interval: Interval | None
|
|
217
|
+
quantity: int | None
|
|
218
|
+
current_period_end: datetime | None
|
|
219
|
+
cancel_at_period_end: bool
|
|
220
|
+
suspended_by_billing: bool
|
|
221
|
+
provider_subscription_id: str | None
|
|
222
|
+
synced_at: datetime | None
|