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,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
+ )
@@ -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