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
sm_billing/crypto.py ADDED
@@ -0,0 +1,89 @@
1
+ """Fernet encryption for the stored Stripe secrets.
2
+
3
+ The pattern of ``sm_ai.crypto``, copied rather than imported: a published
4
+ module must not depend on a sibling optional module. The key derives from the
5
+ running app's ``secret_key`` (SHA-256 → urlsafe base64), installed by
6
+ ``BillingModule.register_settings``. Unlike the AI module there is no
7
+ environment fallback — billing reads no environment, and the app's secret is
8
+ always available once the module is registered.
9
+
10
+ Stored format is ``enc:v1:<fernet token>``:
11
+
12
+ - prefix present and decrypts → plaintext secret
13
+ - prefix present, decryption fails → BillingKeyUnreadableError (secret_key changed)
14
+ - no prefix → treated as plaintext, warned once per field
15
+ (covers values set through the generic Settings screen or set_setting.py)
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import logging
21
+ from base64 import urlsafe_b64encode
22
+ from collections.abc import Callable
23
+ from functools import lru_cache
24
+ from hashlib import sha256
25
+
26
+ from cryptography.fernet import Fernet, InvalidToken
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ ENC_PREFIX = "enc:v1:"
31
+
32
+ _warned_plaintext: set[str] = set()
33
+ _secret_provider: Callable[[], str] | None = None
34
+
35
+
36
+ class BillingKeyUnreadableError(Exception):
37
+ """A stored secret was encrypted under a different ``secret_key``."""
38
+
39
+ def __init__(self, field: str) -> None:
40
+ super().__init__(
41
+ f"Billing setting {field!r} cannot be decrypted — the app's secret_key "
42
+ "changed since it was saved. Re-enter it on /admin/billing/connection."
43
+ )
44
+ self.field = field
45
+
46
+
47
+ def set_secret_provider(provider: Callable[[], str]) -> None:
48
+ """Install the live-secret source (the running app's ``secret_key``)."""
49
+ global _secret_provider
50
+ _secret_provider = provider
51
+
52
+
53
+ def _fernet() -> Fernet:
54
+ secret = _secret_provider() if _secret_provider is not None else ""
55
+ if not secret:
56
+ raise RuntimeError(
57
+ "The app has no secret_key — the billing module needs it to encrypt "
58
+ "stored Stripe secrets."
59
+ )
60
+ return _fernet_for(secret)
61
+
62
+
63
+ @lru_cache(maxsize=4)
64
+ def _fernet_for(secret: str) -> Fernet:
65
+ return Fernet(urlsafe_b64encode(sha256(secret.encode()).digest()))
66
+
67
+
68
+ def encrypt_value(plain: str) -> str:
69
+ """Encrypt a secret for storage."""
70
+ return ENC_PREFIX + _fernet().encrypt(plain.encode()).decode()
71
+
72
+
73
+ def decrypt_value(stored: str, field: str) -> str:
74
+ """Return the plaintext for a stored value (see module docstring)."""
75
+ if not stored:
76
+ return ""
77
+ if stored.startswith(ENC_PREFIX):
78
+ try:
79
+ return _fernet().decrypt(stored[len(ENC_PREFIX) :].encode()).decode()
80
+ except InvalidToken as exc:
81
+ raise BillingKeyUnreadableError(field) from exc
82
+ if field not in _warned_plaintext:
83
+ _warned_plaintext.add(field)
84
+ logger.warning(
85
+ "Billing setting %r is stored unencrypted; re-save it on "
86
+ "/admin/billing/connection to encrypt it.",
87
+ field,
88
+ )
89
+ return stored
sm_billing/deps.py ADDED
@@ -0,0 +1,67 @@
1
+ """FastAPI dependencies for the billing routes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated
6
+
7
+ from fastapi import Depends, Request
8
+ from simple_module_db.deps import get_db
9
+ from sqlalchemy.ext.asyncio import AsyncSession
10
+ from tenants.deps import ActiveTenantContext, require_active_tenant, require_user_id
11
+
12
+ from sm_billing import constants as c
13
+ from sm_billing.checkout import BillingService
14
+ from sm_billing.context import BillingContext, billing_context
15
+ from sm_billing.errors import BillingError
16
+
17
+ ActiveTenant = Annotated[ActiveTenantContext, Depends(require_active_tenant)]
18
+
19
+
20
+ def require_owner(ctx: ActiveTenant) -> ActiveTenantContext:
21
+ """Money decisions belong to the tenant's owner, not every manager."""
22
+ if ctx.role != c.TENANT_ROLE_OWNER:
23
+ raise BillingError("tenant_owner_required", 403)
24
+ return ctx
25
+
26
+
27
+ Owner = Annotated[ActiveTenantContext, Depends(require_owner)]
28
+
29
+
30
+ def get_billing_service(
31
+ request: Request, ctx: ActiveTenant, db: AsyncSession = Depends(get_db)
32
+ ) -> BillingService:
33
+ return BillingService(db, request.app, ctx.tenant_id)
34
+
35
+
36
+ BillingServiceDep = Annotated[BillingService, Depends(get_billing_service)]
37
+
38
+
39
+ def base_url(request: Request) -> str:
40
+ """Origin for provider return URLs: the setting, else this request's origin."""
41
+ configured = getattr(request.app.state, c.PACKAGE).settings.return_base_url
42
+ return (configured or str(request.base_url)).rstrip("/")
43
+
44
+
45
+ def user_email(request: Request) -> str | None:
46
+ return getattr(getattr(request.state, "user", None), "email", None)
47
+
48
+
49
+ async def get_context(request: Request, db: AsyncSession = Depends(get_db)) -> BillingContext:
50
+ """Active tenant, or a billing-suspended one its owner is restoring."""
51
+ require_user_id(request)
52
+ ctx = await billing_context(request, db)
53
+ if ctx is None:
54
+ raise BillingError("tenant_required", 403)
55
+ return ctx
56
+
57
+
58
+ Context = Annotated[BillingContext, Depends(get_context)]
59
+
60
+
61
+ def get_context_service(
62
+ request: Request, ctx: Context, db: AsyncSession = Depends(get_db)
63
+ ) -> BillingService:
64
+ return BillingService(db, request.app, ctx.tenant_id)
65
+
66
+
67
+ ContextServiceDep = Annotated[BillingService, Depends(get_context_service)]
File without changes
@@ -0,0 +1,93 @@
1
+ """Platform-admin billing API (``/api/billing/admin``). CSRF-checked."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from fastapi import APIRouter, Depends, Request
6
+ from simple_module_db.deps import get_db
7
+ from simple_module_hosting.csrf import RequiresCsrf
8
+ from simple_module_hosting.permissions import RequiresPermission
9
+ from sqlalchemy.ext.asyncio import AsyncSession
10
+
11
+ from sm_billing import admin
12
+ from sm_billing import constants as c
13
+ from sm_billing.checkout import plan_out
14
+ from sm_billing.plans import PlanService
15
+ from sm_billing.schemas import (
16
+ AssignIn,
17
+ ConnectionIn,
18
+ ConnectionOut,
19
+ PlanIn,
20
+ PlanOut,
21
+ SubscriptionRow,
22
+ )
23
+
24
+ router = APIRouter(prefix="/admin", dependencies=[Depends(RequiresCsrf())])
25
+
26
+ _VIEW = [Depends(RequiresPermission(c.PERM_PLATFORM_VIEW))]
27
+ _MANAGE = [Depends(RequiresPermission(c.PERM_PLATFORM_MANAGE))]
28
+
29
+
30
+ @router.get("/plans", response_model=list[PlanOut], dependencies=_VIEW)
31
+ async def list_plans(db: AsyncSession = Depends(get_db)) -> list[PlanOut]:
32
+ return [plan_out(p) for p in await PlanService(db).list()]
33
+
34
+
35
+ @router.post("/plans", response_model=PlanOut, status_code=201, dependencies=_MANAGE)
36
+ async def create_plan(
37
+ body: PlanIn, request: Request, db: AsyncSession = Depends(get_db)
38
+ ) -> PlanOut:
39
+ return plan_out(await admin.save_plan(db, request.app, body, None))
40
+
41
+
42
+ @router.put("/plans/{plan_id}", response_model=PlanOut, dependencies=_MANAGE)
43
+ async def update_plan(
44
+ plan_id: int, body: PlanIn, request: Request, db: AsyncSession = Depends(get_db)
45
+ ) -> PlanOut:
46
+ return plan_out(await admin.save_plan(db, request.app, body, plan_id))
47
+
48
+
49
+ @router.post("/plans/{plan_id}/archive", response_model=PlanOut, dependencies=_MANAGE)
50
+ async def archive_plan(plan_id: int, db: AsyncSession = Depends(get_db)) -> PlanOut:
51
+ return plan_out(await PlanService(db).archive(plan_id))
52
+
53
+
54
+ @router.get("/subscriptions", response_model=list[SubscriptionRow], dependencies=_VIEW)
55
+ async def list_subscriptions(
56
+ request: Request,
57
+ status: str | None = None,
58
+ offset: int = 0,
59
+ db: AsyncSession = Depends(get_db),
60
+ ) -> list[SubscriptionRow]:
61
+ return await admin.subscription_rows(db, request.app, status=status, offset=max(0, offset))
62
+
63
+
64
+ @router.post(
65
+ "/subscriptions/{tenant_id}/assign", response_model=SubscriptionRow, dependencies=_MANAGE
66
+ )
67
+ async def assign(
68
+ tenant_id: str, body: AssignIn, request: Request, db: AsyncSession = Depends(get_db)
69
+ ) -> SubscriptionRow:
70
+ await admin.assign(db, request.app, tenant_id, body)
71
+ return await admin.one_row(db, request.app, tenant_id)
72
+
73
+
74
+ @router.post(
75
+ "/subscriptions/{tenant_id}/resync", response_model=SubscriptionRow, dependencies=_MANAGE
76
+ )
77
+ async def resync(
78
+ tenant_id: str, request: Request, db: AsyncSession = Depends(get_db)
79
+ ) -> SubscriptionRow:
80
+ await admin.resync(db, request.app, tenant_id)
81
+ return await admin.one_row(db, request.app, tenant_id)
82
+
83
+
84
+ @router.get("/connection", response_model=ConnectionOut, dependencies=_VIEW)
85
+ async def get_connection(request: Request) -> ConnectionOut:
86
+ return admin.connection_out(request.app)
87
+
88
+
89
+ @router.put("/connection", response_model=ConnectionOut, dependencies=_MANAGE)
90
+ async def put_connection(
91
+ body: ConnectionIn, request: Request, db: AsyncSession = Depends(get_db)
92
+ ) -> ConnectionOut:
93
+ return await admin.save_connection(db, request.app, body)
@@ -0,0 +1,70 @@
1
+ """Platform-admin billing screens under ``/admin/billing``. Reads only."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from fastapi import APIRouter, Depends, Request
6
+ from simple_module_core.permissions import grants
7
+ from simple_module_db.deps import get_db
8
+ from simple_module_hosting.csrf import get_csrf_token
9
+ from simple_module_hosting.inertia_deps import InertiaDep
10
+ from simple_module_hosting.permissions import RequiresPermission, resolved_permissions_for
11
+ from simple_module_inertia import InertiaResponse
12
+ from sqlalchemy.ext.asyncio import AsyncSession
13
+
14
+ from sm_billing import admin
15
+ from sm_billing import constants as c
16
+ from sm_billing.checkout import plan_out
17
+ from sm_billing.plans import PlanService
18
+
19
+ router = APIRouter(dependencies=[Depends(RequiresPermission(c.PERM_PLATFORM_VIEW))])
20
+
21
+
22
+ def _common(request: Request) -> dict:
23
+ services = getattr(request.app.state, c.PACKAGE)
24
+ return {
25
+ "csrf_token": get_csrf_token(request),
26
+ "can_manage": grants(resolved_permissions_for(request), c.PERM_PLATFORM_MANAGE),
27
+ "provider": services.provider.name if services.provider else "",
28
+ "checkout_available": bool(services.provider and services.provider.supports_checkout),
29
+ }
30
+
31
+
32
+ @router.get("/plans", response_model=None)
33
+ async def plans_page(
34
+ request: Request, inertia: InertiaDep, db: AsyncSession = Depends(get_db)
35
+ ) -> InertiaResponse:
36
+ plans = [plan_out(p).model_dump(mode="json") for p in await PlanService(db).list()]
37
+ return await inertia.render(
38
+ c._PAGE_PLANS,
39
+ {**_common(request), "plans": plans, "known_limit_keys": list(c.KNOWN_LIMIT_KEYS)},
40
+ )
41
+
42
+
43
+ @router.get("/subscriptions", response_model=None)
44
+ async def subscriptions_page(
45
+ request: Request, inertia: InertiaDep, db: AsyncSession = Depends(get_db)
46
+ ) -> InertiaResponse:
47
+ status = request.query_params.get("status") or None
48
+ rows = await admin.subscription_rows(db, request.app, status=status)
49
+ plans = [plan_out(p).model_dump(mode="json") for p in await PlanService(db).list()]
50
+ return await inertia.render(
51
+ c._PAGE_SUBSCRIPTIONS,
52
+ {
53
+ **_common(request),
54
+ "rows": [r.model_dump(mode="json") for r in rows],
55
+ "plans": plans,
56
+ "status_filter": status or "",
57
+ },
58
+ )
59
+
60
+
61
+ @router.get("/connection", response_model=None)
62
+ async def connection_page(request: Request, inertia: InertiaDep) -> InertiaResponse:
63
+ return await inertia.render(
64
+ c._PAGE_CONNECTION,
65
+ {
66
+ **_common(request),
67
+ "connection": admin.connection_out(request.app).model_dump(mode="json"),
68
+ "webhook_url": str(request.base_url).rstrip("/") + c.WEBHOOK_PATH,
69
+ },
70
+ )
@@ -0,0 +1,61 @@
1
+ """Tenant billing API (``/api/billing``). CSRF-checked on every mutation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from fastapi import APIRouter, Depends, Request
6
+ from simple_module_core.permissions import grants
7
+ from simple_module_hosting.csrf import RequiresCsrf
8
+ from simple_module_hosting.permissions import RequiresPermission, resolved_permissions_for
9
+
10
+ from sm_billing import constants as c
11
+ from sm_billing.deps import (
12
+ BillingServiceDep,
13
+ Context,
14
+ ContextServiceDep,
15
+ Owner,
16
+ base_url,
17
+ user_email,
18
+ )
19
+ from sm_billing.errors import BillingError
20
+ from sm_billing.schemas import PlanChoice, StatusOut, UrlOut
21
+
22
+ router = APIRouter(dependencies=[Depends(RequiresCsrf())])
23
+
24
+ _MANAGE = [Depends(RequiresPermission(c.PERM_MANAGE))]
25
+
26
+
27
+ @router.get("/status", response_model=StatusOut)
28
+ async def status(request: Request, ctx: Context, service: ContextServiceDep) -> StatusOut:
29
+ if not ctx.restore:
30
+ _require(request, c.PERM_VIEW)
31
+ return await service.status()
32
+
33
+
34
+ @router.post("/checkout", response_model=UrlOut, dependencies=_MANAGE)
35
+ async def checkout(
36
+ body: PlanChoice, request: Request, service: BillingServiceDep, _: Owner
37
+ ) -> UrlOut:
38
+ url = await service.checkout(
39
+ body.plan_id, body.interval, email=user_email(request), base=base_url(request)
40
+ )
41
+ return UrlOut(url=url)
42
+
43
+
44
+ @router.post("/portal", response_model=UrlOut)
45
+ async def portal(request: Request, ctx: Context, service: ContextServiceDep) -> UrlOut:
46
+ """Also open to the owner of a billing-suspended tenant — it is how they pay."""
47
+ if ctx.role != c.TENANT_ROLE_OWNER:
48
+ raise BillingError("tenant_owner_required", 403)
49
+ if not ctx.restore:
50
+ _require(request, c.PERM_MANAGE)
51
+ return UrlOut(url=await service.portal(base=base_url(request)))
52
+
53
+
54
+ def _require(request: Request, permission: str) -> None:
55
+ if not grants(resolved_permissions_for(request), permission):
56
+ raise BillingError("forbidden", 403)
57
+
58
+
59
+ @router.post("/change-plan", response_model=StatusOut, dependencies=_MANAGE)
60
+ async def change_plan(body: PlanChoice, service: BillingServiceDep, _: Owner) -> StatusOut:
61
+ return await service.change_plan(body.plan_id, body.interval)
@@ -0,0 +1,26 @@
1
+ """Router wiring, kept out of ``module.py`` so the hooks stay one line."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from fastapi import APIRouter
6
+
7
+
8
+ def include(api_router: APIRouter, view_router: APIRouter) -> None:
9
+ """Mount the tenant API, admin API, webhook and tenant views."""
10
+ from sm_billing.endpoints.admin_api import router as admin_api
11
+ from sm_billing.endpoints.api import router as api
12
+ from sm_billing.endpoints.views import router as views
13
+ from sm_billing.endpoints.webhooks import router as webhooks
14
+
15
+ api_router.include_router(admin_api)
16
+ api_router.include_router(api)
17
+ # No CSRF on the webhook: Stripe has no session, the signature is the auth.
18
+ view_router.include_router(webhooks)
19
+ view_router.include_router(views)
20
+
21
+
22
+ def include_admin(admin_router: APIRouter) -> None:
23
+ """Mount the platform-admin Inertia views under ``/admin/billing``."""
24
+ from sm_billing.endpoints.admin_views import router as admin_views
25
+
26
+ admin_router.include_router(admin_views)
@@ -0,0 +1,56 @@
1
+ """Tenant billing screen (``/billing/``). Reads only; mutations go through the API.
2
+
3
+ The permission is checked in the handler rather than as a route dependency:
4
+ a signed-in user with no active tenant holds no ``tenant:*`` role yet, and
5
+ should be sent to pick an organisation, not shown a 403.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from fastapi import APIRouter, Depends, Request
11
+ from fastapi.responses import RedirectResponse
12
+ from simple_module_core.permissions import grants
13
+ from simple_module_db.deps import get_db
14
+ from simple_module_hosting.csrf import get_csrf_token
15
+ from simple_module_hosting.inertia_deps import InertiaDep
16
+ from simple_module_hosting.permissions import resolved_permissions_for
17
+ from simple_module_inertia import InertiaResponse
18
+ from sqlalchemy.ext.asyncio import AsyncSession
19
+
20
+ from sm_billing import constants as c
21
+ from sm_billing.checkout import BillingService
22
+ from sm_billing.context import billing_context
23
+
24
+ router = APIRouter()
25
+
26
+ NO_TENANT_REDIRECT = "/tenants/?reason=tenant_required"
27
+
28
+
29
+ def _perm(request: Request, permission: str) -> bool:
30
+ return grants(resolved_permissions_for(request), permission)
31
+
32
+
33
+ @router.get("/", response_model=None)
34
+ async def billing_page(
35
+ request: Request, inertia: InertiaDep, db: AsyncSession = Depends(get_db)
36
+ ) -> InertiaResponse | RedirectResponse:
37
+ ctx = await billing_context(request, db)
38
+ if ctx is None or (not ctx.restore and not _perm(request, c.PERM_VIEW)):
39
+ return RedirectResponse(NO_TENANT_REDIRECT, status_code=303)
40
+ service = BillingService(db, request.app, ctx.tenant_id)
41
+ # Restore mode is pay-only: no plan changes from a suspended organisation.
42
+ can_manage = (
43
+ not ctx.restore and _perm(request, c.PERM_MANAGE) and ctx.role == c.TENANT_ROLE_OWNER
44
+ )
45
+ status = await service.status()
46
+ return await inertia.render(
47
+ c._PAGE_BILLING,
48
+ {
49
+ "status": status.model_dump(mode="json"),
50
+ "plans": [p.model_dump(mode="json") for p in await service.public_plans()],
51
+ "can_manage": can_manage,
52
+ "restore": ctx.restore,
53
+ "csrf_token": get_csrf_token(request),
54
+ "checkout": request.query_params.get("checkout", ""),
55
+ },
56
+ )
@@ -0,0 +1,27 @@
1
+ """``POST /billing/webhooks/stripe`` — public; the signature is the authentication.
2
+
3
+ Mounted on the view router (prefix ``/billing``) with no CSRF dependency, and
4
+ exempted from ``AuthMiddleware`` in ``BillingModule.register_public_routes``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from fastapi import APIRouter, Request
10
+ from fastapi.responses import JSONResponse
11
+
12
+ from sm_billing import constants as c
13
+ from sm_billing.webhooks import process_webhook
14
+
15
+ router = APIRouter()
16
+
17
+
18
+ @router.post("/webhooks/stripe", include_in_schema=False)
19
+ async def stripe_webhook(request: Request) -> JSONResponse:
20
+ services = getattr(request.app.state, c.PACKAGE)
21
+ provider = services.provider
22
+ if provider is None or provider.name != c.PROVIDER_STRIPE:
23
+ return JSONResponse({"detail": "stripe_not_configured"}, status_code=404)
24
+ status, detail = await process_webhook(
25
+ request.app, provider, await request.body(), request.headers
26
+ )
27
+ return JSONResponse({"detail": detail}, status_code=status)
@@ -0,0 +1,30 @@
1
+ """``PlanEntitlements`` — the tenants entitlement seam, answered from plans.
2
+
3
+ Installed as ``app.state.tenants.entitlements`` at startup. ``tenants`` reads
4
+ that attribute per request, so consumers keep depending on its protocol and
5
+ never import billing. Each call opens its own short session: callers (the
6
+ tenants service, a consumer's route) hold their own and must not see ours.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from sqlalchemy.ext.asyncio import async_sessionmaker
12
+
13
+ from sm_billing.resolve import effective_plan
14
+
15
+
16
+ class PlanEntitlements:
17
+ def __init__(self, session_factory: async_sessionmaker) -> None:
18
+ self._session_factory = session_factory
19
+
20
+ async def limit(self, tenant_id: str, key: str) -> int | None:
21
+ """The plan's limit for ``key``; an absent key is unlimited (``None``)."""
22
+ async with self._session_factory() as session:
23
+ plan, _ = await effective_plan(session, tenant_id)
24
+ value = plan.limits.get(key)
25
+ return None if value is None else int(value)
26
+
27
+ async def has_feature(self, tenant_id: str, key: str) -> bool:
28
+ async with self._session_factory() as session:
29
+ plan, _ = await effective_plan(session, tenant_id)
30
+ return key in plan.features
sm_billing/errors.py ADDED
@@ -0,0 +1,41 @@
1
+ """Billing errors and their JSON mapping (``{"detail": code, ...}``)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import Any
7
+
8
+ from fastapi import FastAPI, Request
9
+ from fastapi.responses import JSONResponse
10
+
11
+ from sm_billing.contracts.provider import ProviderError
12
+ from sm_billing.plans import PlanError
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class BillingError(Exception):
18
+ def __init__(self, code: str, status_code: int = 400, **extra: Any) -> None:
19
+ super().__init__(code)
20
+ self.code = code
21
+ self.status_code = status_code
22
+ self.extra = extra
23
+
24
+
25
+ async def _billing_error(_: Request, exc: BillingError) -> JSONResponse:
26
+ return JSONResponse({"detail": exc.code, **exc.extra}, status_code=exc.status_code)
27
+
28
+
29
+ async def _plan_error(_: Request, exc: PlanError) -> JSONResponse:
30
+ return JSONResponse({"detail": exc.code}, status_code=exc.status_code)
31
+
32
+
33
+ async def _provider_error(request: Request, exc: ProviderError) -> JSONResponse:
34
+ logger.warning("billing: provider call failed on %s: %s", request.url.path, exc)
35
+ return JSONResponse({"detail": "provider_error", "message": str(exc)}, status_code=502)
36
+
37
+
38
+ def install_exception_handlers(app: FastAPI) -> None:
39
+ app.add_exception_handler(BillingError, _billing_error)
40
+ app.add_exception_handler(PlanError, _plan_error)
41
+ app.add_exception_handler(ProviderError, _provider_error)
@@ -0,0 +1,50 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { api, BILLING_API } from '../utils/api';
3
+ import { isActivated } from '../utils/format';
4
+ import type { Status } from '../utils/types';
5
+
6
+ const INTERVAL_MS = 2000;
7
+ const MAX_TRIES = 15;
8
+
9
+ /**
10
+ * After Stripe redirects back with ``?checkout=success`` the webhook may not
11
+ * have landed yet — the redirect itself grants nothing. Poll the status until
12
+ * a provider subscription shows up, then hand it over; give up after ~30s.
13
+ */
14
+ export function useCheckoutPolling(
15
+ active: boolean,
16
+ csrf: string,
17
+ onActivated: () => void,
18
+ ): 'idle' | 'waiting' | 'timeout' {
19
+ const [state, setState] = useState<'idle' | 'waiting' | 'timeout'>(active ? 'waiting' : 'idle');
20
+
21
+ useEffect(() => {
22
+ if (!active) return;
23
+ let tries = 0;
24
+ let cancelled = false;
25
+ const timer = setInterval(async () => {
26
+ tries += 1;
27
+ try {
28
+ const status = await api<Status>(`${BILLING_API}/status`, csrf);
29
+ if (!cancelled && isActivated(status)) {
30
+ clearInterval(timer);
31
+ setState('idle');
32
+ onActivated();
33
+ return;
34
+ }
35
+ } catch {
36
+ // Transient; keep polling until the budget runs out.
37
+ }
38
+ if (tries >= MAX_TRIES && !cancelled) {
39
+ clearInterval(timer);
40
+ setState('timeout');
41
+ }
42
+ }, INTERVAL_MS);
43
+ return () => {
44
+ cancelled = true;
45
+ clearInterval(timer);
46
+ };
47
+ }, [active, csrf, onActivated]);
48
+
49
+ return state;
50
+ }