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
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
|
+
}
|