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/lifecycle.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""What a subscription's status does to its tenant.
|
|
2
|
+
|
|
3
|
+
``decide`` is the whole policy as a pure function; ``apply_lifecycle`` carries
|
|
4
|
+
it out through ``TenantService.set_status`` so the tenants module publishes
|
|
5
|
+
``TenantStatusChanged`` and drops its membership cache exactly as an admin
|
|
6
|
+
suspension would.
|
|
7
|
+
|
|
8
|
+
- trialing / active / past_due / canceled / incomplete: reactivate the tenant
|
|
9
|
+
*only if billing suspended it*. An admin's suspension is never lifted here.
|
|
10
|
+
- unpaid: suspend an active tenant and remember that billing did it. A tenant
|
|
11
|
+
an admin already suspended stays theirs.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import TYPE_CHECKING
|
|
18
|
+
|
|
19
|
+
from tenants.constants import TenantStatus
|
|
20
|
+
from tenants.models import Tenant
|
|
21
|
+
from tenants.resolver import make_invalidator
|
|
22
|
+
from tenants.service import TenantService
|
|
23
|
+
|
|
24
|
+
from sm_billing.constants import SubscriptionStatus
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from fastapi import FastAPI
|
|
28
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
29
|
+
|
|
30
|
+
from sm_billing.models import Subscription
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class Actions:
|
|
35
|
+
set_status: TenantStatus | None
|
|
36
|
+
suspended_by_billing: bool
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def decide(status: str, suspended_by_billing: bool, tenant_status: str) -> Actions:
|
|
40
|
+
if status == SubscriptionStatus.UNPAID:
|
|
41
|
+
if tenant_status == TenantStatus.ACTIVE:
|
|
42
|
+
return Actions(TenantStatus.SUSPENDED, True)
|
|
43
|
+
return Actions(None, suspended_by_billing)
|
|
44
|
+
if suspended_by_billing and tenant_status == TenantStatus.SUSPENDED:
|
|
45
|
+
return Actions(TenantStatus.ACTIVE, False)
|
|
46
|
+
return Actions(None, False)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def tenant_service(db: AsyncSession, app: FastAPI) -> TenantService:
|
|
50
|
+
"""A ``TenantService`` wired like ``tenants.deps.get_tenant_service``."""
|
|
51
|
+
return TenantService(
|
|
52
|
+
db,
|
|
53
|
+
bus=app.state.sm.event_bus,
|
|
54
|
+
invalidate=make_invalidator(app),
|
|
55
|
+
entitlements=app.state.tenants.entitlements,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
async def apply_lifecycle(db: AsyncSession, app: FastAPI, sub: Subscription) -> None:
|
|
60
|
+
"""Apply ``decide`` to ``sub``'s tenant; flushes, never commits.
|
|
61
|
+
|
|
62
|
+
The status event is queued with ``db.on_commit`` (every framework session
|
|
63
|
+
is a ``RequestSession``), so a caller outside a request must commit with
|
|
64
|
+
``simple_module_db.finalize_session`` — a bare ``commit()`` drops it.
|
|
65
|
+
"""
|
|
66
|
+
service = tenant_service(db, app)
|
|
67
|
+
tenant = await db.get(Tenant, sub.tenant_id)
|
|
68
|
+
if tenant is None:
|
|
69
|
+
return
|
|
70
|
+
actions = decide(sub.status, sub.suspended_by_billing, tenant.status)
|
|
71
|
+
sub.suspended_by_billing = actions.suspended_by_billing
|
|
72
|
+
if actions.set_status is not None:
|
|
73
|
+
await service.set_status(tenant.id, actions.set_status)
|
|
74
|
+
await db.flush()
|
sm_billing/models.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""SQLModel tables for the Billing module.
|
|
2
|
+
|
|
3
|
+
None use ``MultiTenantMixin``: webhooks, reconcile and the admin list span
|
|
4
|
+
tenants by nature — the same reasoning as ``tenants``' own tables. Each row
|
|
5
|
+
that belongs to a tenant carries an explicit ``tenant_id`` and every query
|
|
6
|
+
filters on it. Nothing billing-specific is added to ``tenants_tenant``.
|
|
7
|
+
|
|
8
|
+
``tenant_id`` is a plain string, not a foreign key: each module owns its own
|
|
9
|
+
``MetaData``, so a cross-module FK cannot resolve (``tenants`` stores
|
|
10
|
+
``user_id`` the same way). Deleting a tenant therefore leaves its billing rows
|
|
11
|
+
behind — see the README's tenant-deletion note.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from datetime import UTC, datetime
|
|
17
|
+
|
|
18
|
+
from simple_module_db.base import create_module_base
|
|
19
|
+
from simple_module_db.mixins import AuditMixin
|
|
20
|
+
from sqlalchemy import JSON, Column, DateTime, Text
|
|
21
|
+
from sqlmodel import Field
|
|
22
|
+
|
|
23
|
+
from sm_billing.constants import SubscriptionStatus
|
|
24
|
+
|
|
25
|
+
Base = create_module_base("billing")
|
|
26
|
+
|
|
27
|
+
_TENANT_ID_LEN = 32 # tenants: uuid4().hex
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _utcnow() -> datetime:
|
|
31
|
+
return datetime.now(UTC)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _tz_column(*, nullable: bool = True) -> Column:
|
|
35
|
+
return Column(DateTime(timezone=True), nullable=nullable)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Plan(Base, AuditMixin, table=True): # ty: ignore[unsupported-base]
|
|
39
|
+
"""A sellable plan. Archived, never deleted, so subscriptions keep a valid plan."""
|
|
40
|
+
|
|
41
|
+
__tablename__ = "billing_plan"
|
|
42
|
+
|
|
43
|
+
id: int | None = Field(default=None, primary_key=True)
|
|
44
|
+
key: str = Field(max_length=64, unique=True, index=True)
|
|
45
|
+
name: str = Field(max_length=120)
|
|
46
|
+
description: str = Field(default="", max_length=500)
|
|
47
|
+
pricing_model: str = Field(max_length=20)
|
|
48
|
+
currency: str = Field(default="eur", max_length=3)
|
|
49
|
+
# Minor units, display only — Stripe charges what the price says.
|
|
50
|
+
amount_month: int | None = Field(default=None)
|
|
51
|
+
amount_year: int | None = Field(default=None)
|
|
52
|
+
stripe_price_month: str | None = Field(default=None, max_length=255, unique=True)
|
|
53
|
+
stripe_price_year: str | None = Field(default=None, max_length=255, unique=True)
|
|
54
|
+
trial_days: int = Field(default=0)
|
|
55
|
+
# {entitlement key: int}; an absent key is unlimited, 0 forbids.
|
|
56
|
+
limits: dict = Field(default_factory=dict, sa_column=Column(JSON, nullable=False))
|
|
57
|
+
features: list = Field(default_factory=list, sa_column=Column(JSON, nullable=False))
|
|
58
|
+
is_default: bool = Field(default=False)
|
|
59
|
+
is_public: bool = Field(default=True)
|
|
60
|
+
sort_order: int = Field(default=0)
|
|
61
|
+
archived_at: datetime | None = Field(default=None, sa_column=_tz_column())
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class Customer(Base, AuditMixin, table=True): # ty: ignore[unsupported-base]
|
|
65
|
+
"""The tenant's identity at the payment provider. Created at first checkout."""
|
|
66
|
+
|
|
67
|
+
__tablename__ = "billing_customer"
|
|
68
|
+
|
|
69
|
+
tenant_id: str = Field(primary_key=True, max_length=_TENANT_ID_LEN)
|
|
70
|
+
provider: str = Field(max_length=20)
|
|
71
|
+
provider_customer_id: str | None = Field(default=None, max_length=255, unique=True, index=True)
|
|
72
|
+
email: str | None = Field(default=None, max_length=320)
|
|
73
|
+
# The one open Checkout session, expired when a new one is started so two
|
|
74
|
+
# tabs cannot both be paid into two subscriptions.
|
|
75
|
+
checkout_session_id: str | None = Field(default=None, max_length=255)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class Subscription(Base, AuditMixin, table=True): # ty: ignore[unsupported-base]
|
|
79
|
+
"""A tenant's one current subscription — the local mirror of the provider's."""
|
|
80
|
+
|
|
81
|
+
__tablename__ = "billing_subscription"
|
|
82
|
+
|
|
83
|
+
id: int | None = Field(default=None, primary_key=True)
|
|
84
|
+
tenant_id: str = Field(max_length=_TENANT_ID_LEN, unique=True, index=True)
|
|
85
|
+
plan_id: int = Field(foreign_key="billing_plan.id")
|
|
86
|
+
status: str = Field(default=SubscriptionStatus.ACTIVE, max_length=20, index=True)
|
|
87
|
+
interval: str | None = Field(default=None, max_length=10)
|
|
88
|
+
provider_subscription_id: str | None = Field(
|
|
89
|
+
default=None, max_length=255, unique=True, index=True
|
|
90
|
+
)
|
|
91
|
+
quantity: int = Field(default=1)
|
|
92
|
+
trial_end: datetime | None = Field(default=None, sa_column=_tz_column())
|
|
93
|
+
current_period_end: datetime | None = Field(default=None, sa_column=_tz_column())
|
|
94
|
+
cancel_at_period_end: bool = Field(default=False)
|
|
95
|
+
# Billing only ever reactivates a tenant it suspended itself.
|
|
96
|
+
suspended_by_billing: bool = Field(default=False)
|
|
97
|
+
synced_at: datetime | None = Field(default=None, sa_column=_tz_column())
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class WebhookEvent(Base, table=True): # ty: ignore[unsupported-base]
|
|
101
|
+
"""One row per provider event id: idempotency and an audit trail."""
|
|
102
|
+
|
|
103
|
+
__tablename__ = "billing_webhook_event"
|
|
104
|
+
|
|
105
|
+
id: str = Field(primary_key=True, max_length=255)
|
|
106
|
+
provider: str = Field(max_length=20)
|
|
107
|
+
type: str = Field(max_length=100)
|
|
108
|
+
received_at: datetime = Field(
|
|
109
|
+
default_factory=_utcnow, sa_column=Column(DateTime(timezone=True), nullable=False)
|
|
110
|
+
)
|
|
111
|
+
processed_at: datetime | None = Field(default=None, sa_column=_tz_column())
|
|
112
|
+
error: str | None = Field(default=None, sa_column=Column(Text, nullable=True))
|
sm_billing/module.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Billing — plans, subscriptions and payment for tenants.
|
|
2
|
+
|
|
3
|
+
Plugs into the seams ``simple_module_tenants`` exposes and changes none of
|
|
4
|
+
them: it installs ``PlanEntitlements`` as ``app.state.tenants.entitlements``
|
|
5
|
+
(so the seat limit ``tenants`` already enforces comes from the tenant's plan),
|
|
6
|
+
reacts to membership events to keep per-seat quantities in step, and drives
|
|
7
|
+
``TenantService.set_status`` when a subscription stops being paid.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import importlib
|
|
13
|
+
import importlib.metadata
|
|
14
|
+
import logging
|
|
15
|
+
import weakref
|
|
16
|
+
from typing import TYPE_CHECKING
|
|
17
|
+
|
|
18
|
+
from simple_module_core import ModuleBase, ModuleMeta
|
|
19
|
+
from simple_module_core.menu import MenuItem, MenuRegistry, MenuSection
|
|
20
|
+
|
|
21
|
+
from sm_billing import constants as c
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from fastapi import APIRouter, FastAPI
|
|
25
|
+
from simple_module_core.events import EventBus
|
|
26
|
+
from simple_module_core.permissions import PermissionRegistry
|
|
27
|
+
from simple_module_core.public_routes import PublicRouteRegistry
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
_VERSION = importlib.metadata.version("simple_module_billing")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class BillingModule(ModuleBase):
|
|
35
|
+
meta = ModuleMeta(
|
|
36
|
+
name=c.MODULE_NAME,
|
|
37
|
+
route_prefix=c.ROUTE_PREFIX_API,
|
|
38
|
+
view_prefix=c.VIEW_PREFIX,
|
|
39
|
+
admin_view_prefix=c.ADMIN_VIEW_PREFIX,
|
|
40
|
+
depends_on=[c._MODULE_TENANTS, c._MODULE_SETTINGS],
|
|
41
|
+
version=_VERSION,
|
|
42
|
+
# The framework API version (1.0.0) is decoupled from the framework
|
|
43
|
+
# package version (0.0.x) — this range is correct as written.
|
|
44
|
+
requires_framework=">=1.0,<2.0",
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
def register_settings(self, app: FastAPI) -> None:
|
|
48
|
+
from sm_billing import crypto
|
|
49
|
+
from sm_billing.services import BillingServices
|
|
50
|
+
from sm_billing.settings import BillingSettings
|
|
51
|
+
|
|
52
|
+
register_module_settings = importlib.import_module(
|
|
53
|
+
"settings.registration"
|
|
54
|
+
).register_module_settings
|
|
55
|
+
register_module_settings(
|
|
56
|
+
app, c.PACKAGE, BillingSettings, lambda s: BillingServices(settings=s)
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
# Encrypt under the secret the running app holds (hosts may inject it
|
|
60
|
+
# programmatically). Through a weakref: the provider is process-global
|
|
61
|
+
# and a strong closure would keep discarded test apps alive.
|
|
62
|
+
app_ref = weakref.ref(app)
|
|
63
|
+
|
|
64
|
+
def _live_secret() -> str:
|
|
65
|
+
live_app = app_ref()
|
|
66
|
+
sm = getattr(getattr(live_app, "state", None), "sm", None)
|
|
67
|
+
return getattr(getattr(sm, "settings", None), "secret_key", "") or ""
|
|
68
|
+
|
|
69
|
+
crypto.set_secret_provider(_live_secret)
|
|
70
|
+
|
|
71
|
+
def register_exception_handlers(self, app: FastAPI) -> None:
|
|
72
|
+
from sm_billing.errors import install_exception_handlers
|
|
73
|
+
|
|
74
|
+
install_exception_handlers(app)
|
|
75
|
+
|
|
76
|
+
def register_permissions(self, registry: PermissionRegistry) -> None:
|
|
77
|
+
registry.add_group(
|
|
78
|
+
c.MODULE_NAME,
|
|
79
|
+
[c.PERM_VIEW, c.PERM_MANAGE, c.PERM_PLATFORM_VIEW, c.PERM_PLATFORM_MANAGE],
|
|
80
|
+
)
|
|
81
|
+
registry.map_role(c.ROLE_TENANT_OWNER, [c.PERM_VIEW, c.PERM_MANAGE])
|
|
82
|
+
registry.map_role(c.ROLE_TENANT_ADMIN, [c.PERM_VIEW])
|
|
83
|
+
|
|
84
|
+
def register_menu_items(self, registry: MenuRegistry) -> None:
|
|
85
|
+
registry.add_many(
|
|
86
|
+
[
|
|
87
|
+
MenuItem(
|
|
88
|
+
label=c.MENU_LABEL,
|
|
89
|
+
url=c.MENU_URL,
|
|
90
|
+
icon=c.MENU_ICON,
|
|
91
|
+
order=c.MENU_ORDER,
|
|
92
|
+
section=MenuSection.SIDEBAR,
|
|
93
|
+
permissions=[c.PERM_VIEW],
|
|
94
|
+
),
|
|
95
|
+
MenuItem(
|
|
96
|
+
label=c.ADMIN_MENU_LABEL,
|
|
97
|
+
url=c.ADMIN_MENU_URL,
|
|
98
|
+
icon=c.MENU_ICON,
|
|
99
|
+
order=c.MENU_ORDER,
|
|
100
|
+
section=MenuSection.ADMIN_SIDEBAR,
|
|
101
|
+
permissions=[c.PERM_PLATFORM_VIEW],
|
|
102
|
+
group=c.ADMIN_MENU_GROUP,
|
|
103
|
+
group_key=c.ADMIN_MENU_GROUP_KEY,
|
|
104
|
+
),
|
|
105
|
+
]
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
def register_routes(self, api_router: APIRouter, view_router: APIRouter) -> None:
|
|
109
|
+
from sm_billing.endpoints import routers
|
|
110
|
+
|
|
111
|
+
routers.include(api_router, view_router)
|
|
112
|
+
|
|
113
|
+
def register_admin_routes(self, admin_router: APIRouter) -> None:
|
|
114
|
+
from sm_billing.endpoints import routers
|
|
115
|
+
|
|
116
|
+
routers.include_admin(admin_router)
|
|
117
|
+
|
|
118
|
+
def register_public_routes(self, registry: PublicRouteRegistry) -> None:
|
|
119
|
+
# Stripe calls this unauthenticated; the signature is the auth.
|
|
120
|
+
registry.add_exact(c.WEBHOOK_PATH, methods={"POST"})
|
|
121
|
+
|
|
122
|
+
def register_event_handlers(self, bus: EventBus, app: FastAPI | None = None) -> None:
|
|
123
|
+
if app is None:
|
|
124
|
+
return
|
|
125
|
+
from sm_billing import seats
|
|
126
|
+
|
|
127
|
+
seats.subscribe(bus, app)
|
|
128
|
+
|
|
129
|
+
async def on_startup(self, app: FastAPI) -> None:
|
|
130
|
+
from sm_billing import startup
|
|
131
|
+
|
|
132
|
+
await startup.run(app)
|
sm_billing/package.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@simple-module-py/billing",
|
|
3
|
+
"version": "0.0.8",
|
|
4
|
+
"private": true,
|
|
5
|
+
"description": "Frontend assets for the Billing module",
|
|
6
|
+
"peerDependencies": {
|
|
7
|
+
"react": "^19.0.0",
|
|
8
|
+
"react-dom": "^19.0.0",
|
|
9
|
+
"@inertiajs/react": "^3.7.0",
|
|
10
|
+
"@simple-module-py/ui": "*",
|
|
11
|
+
"sonner": "^2.0.0"
|
|
12
|
+
},
|
|
13
|
+
"devDependencies": {
|
|
14
|
+
"@simple-module-py/tsconfig": "*"
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {}
|
|
17
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { Head, router, usePage } from '@inertiajs/react';
|
|
2
|
+
import { ConfirmActionDialog } from '@simple-module-py/ui/components/ConfirmActionDialog';
|
|
3
|
+
import { InlineBanner } from '@simple-module-py/ui/components/InlineBanner';
|
|
4
|
+
import { PageShell } from '@simple-module-py/ui/components/PageShell';
|
|
5
|
+
import { SegmentedControl } from '@simple-module-py/ui/components/SegmentedControl';
|
|
6
|
+
import { Button } from '@simple-module-py/ui/components/ui/button';
|
|
7
|
+
import { AuthenticatedLayout } from '@simple-module-py/ui/layouts/AuthenticatedLayout';
|
|
8
|
+
import { ArrowRightLeft, Info, Loader2, Lock } from 'lucide-react';
|
|
9
|
+
import { useCallback, useState } from 'react';
|
|
10
|
+
import { toast } from 'sonner';
|
|
11
|
+
import { CurrentPlanCard } from '../components/CurrentPlanCard';
|
|
12
|
+
import { PlanCard } from '../components/PlanCard';
|
|
13
|
+
import { useCheckoutPolling } from '../hooks/useCheckoutPolling';
|
|
14
|
+
import { api, BILLING_API } from '../utils/api';
|
|
15
|
+
import { type PlanCta, planCta, priceLabel } from '../utils/format';
|
|
16
|
+
import type { Interval, Plan, Status } from '../utils/types';
|
|
17
|
+
|
|
18
|
+
interface Props {
|
|
19
|
+
status: Status;
|
|
20
|
+
plans: Plan[];
|
|
21
|
+
can_manage: boolean;
|
|
22
|
+
/** Suspended for non-payment: paying is the only thing offered. */
|
|
23
|
+
restore: boolean;
|
|
24
|
+
csrf_token: string;
|
|
25
|
+
checkout: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function Billing() {
|
|
29
|
+
const { status, plans, can_manage, restore, csrf_token, checkout } = usePage<{
|
|
30
|
+
props: Props;
|
|
31
|
+
}>().props as unknown as Props;
|
|
32
|
+
const [interval, setBillingInterval] = useState<Interval>(
|
|
33
|
+
status.subscription?.interval ?? 'month',
|
|
34
|
+
);
|
|
35
|
+
const [busy, setBusy] = useState(false);
|
|
36
|
+
const [pending, setPending] = useState<{ plan: Plan; cta: PlanCta } | null>(null);
|
|
37
|
+
const reload = useCallback(() => router.reload(), []);
|
|
38
|
+
const polling = useCheckoutPolling(checkout === 'success', csrf_token, reload);
|
|
39
|
+
const sellsYearly = plans.some((p) => p.stripe_price_year);
|
|
40
|
+
|
|
41
|
+
async function run(work: () => Promise<void>) {
|
|
42
|
+
setBusy(true);
|
|
43
|
+
try {
|
|
44
|
+
await work();
|
|
45
|
+
} catch (err) {
|
|
46
|
+
toast.error((err as Error).message);
|
|
47
|
+
} finally {
|
|
48
|
+
setBusy(false);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const openPortal = () =>
|
|
53
|
+
run(async () => {
|
|
54
|
+
const { url } = await api<{ url: string }>(`${BILLING_API}/portal`, csrf_token, {
|
|
55
|
+
body: {},
|
|
56
|
+
});
|
|
57
|
+
window.location.assign(url);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
function choose(plan: Plan, cta: PlanCta) {
|
|
61
|
+
if (cta === 'checkout') {
|
|
62
|
+
void run(async () => {
|
|
63
|
+
const { url } = await api<{ url: string }>(`${BILLING_API}/checkout`, csrf_token, {
|
|
64
|
+
body: { plan_id: plan.id, interval },
|
|
65
|
+
});
|
|
66
|
+
window.location.assign(url);
|
|
67
|
+
});
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
setPending({ plan, cta });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const confirmChange = () =>
|
|
74
|
+
run(async () => {
|
|
75
|
+
if (!pending) return;
|
|
76
|
+
await api<Status>(`${BILLING_API}/change-plan`, csrf_token, {
|
|
77
|
+
body: { plan_id: pending.plan.id, interval },
|
|
78
|
+
});
|
|
79
|
+
toast.success(
|
|
80
|
+
pending.cta === 'downgrade'
|
|
81
|
+
? 'Your plan ends at the end of the billing period.'
|
|
82
|
+
: `Switched to ${pending.plan.name}.`,
|
|
83
|
+
);
|
|
84
|
+
setPending(null);
|
|
85
|
+
reload();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<>
|
|
90
|
+
<Head title="Billing" />
|
|
91
|
+
<PageShell title="Billing" description="Your organisation's plan, seats and payment.">
|
|
92
|
+
<div className="space-y-8">
|
|
93
|
+
{polling === 'waiting' && (
|
|
94
|
+
<InlineBanner
|
|
95
|
+
icon={Loader2}
|
|
96
|
+
title="Activating your subscription…"
|
|
97
|
+
description="Stripe confirmed the payment; this page updates once it reaches us."
|
|
98
|
+
/>
|
|
99
|
+
)}
|
|
100
|
+
{polling === 'timeout' && (
|
|
101
|
+
<InlineBanner
|
|
102
|
+
icon={Info}
|
|
103
|
+
tone="warning"
|
|
104
|
+
title="Still waiting for Stripe"
|
|
105
|
+
description="Your payment went through, but the confirmation is late. Refresh in a minute."
|
|
106
|
+
/>
|
|
107
|
+
)}
|
|
108
|
+
{checkout === 'cancel' && (
|
|
109
|
+
<InlineBanner icon={Info} title="Checkout cancelled — nothing was charged." />
|
|
110
|
+
)}
|
|
111
|
+
{restore && (
|
|
112
|
+
<InlineBanner
|
|
113
|
+
icon={Lock}
|
|
114
|
+
tone="warning"
|
|
115
|
+
title="This organisation is suspended for non-payment"
|
|
116
|
+
description={
|
|
117
|
+
status.portal_available
|
|
118
|
+
? 'Update your payment method and settle any open invoice; access returns as soon as Stripe confirms the payment.'
|
|
119
|
+
: 'Contact the site administrator to restore access.'
|
|
120
|
+
}
|
|
121
|
+
action={
|
|
122
|
+
status.portal_available ? (
|
|
123
|
+
<Button size="sm" onClick={openPortal} disabled={busy}>
|
|
124
|
+
Pay now
|
|
125
|
+
</Button>
|
|
126
|
+
) : undefined
|
|
127
|
+
}
|
|
128
|
+
/>
|
|
129
|
+
)}
|
|
130
|
+
<CurrentPlanCard
|
|
131
|
+
status={status}
|
|
132
|
+
canManage={can_manage || restore}
|
|
133
|
+
busy={busy}
|
|
134
|
+
onPortal={openPortal}
|
|
135
|
+
/>
|
|
136
|
+
|
|
137
|
+
<section className="space-y-4" hidden={restore}>
|
|
138
|
+
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
139
|
+
<h2 className="text-lg font-semibold">Plans</h2>
|
|
140
|
+
{sellsYearly && (
|
|
141
|
+
<SegmentedControl
|
|
142
|
+
aria-label="Billing period"
|
|
143
|
+
value={interval}
|
|
144
|
+
onChange={setBillingInterval}
|
|
145
|
+
options={[
|
|
146
|
+
{ value: 'month', label: 'Monthly' },
|
|
147
|
+
{ value: 'year', label: 'Yearly' },
|
|
148
|
+
]}
|
|
149
|
+
/>
|
|
150
|
+
)}
|
|
151
|
+
</div>
|
|
152
|
+
{!can_manage && (
|
|
153
|
+
<p className="text-sm text-muted-foreground">
|
|
154
|
+
Only the organisation owner can change the plan.
|
|
155
|
+
</p>
|
|
156
|
+
)}
|
|
157
|
+
{!status.checkout_available && can_manage && (
|
|
158
|
+
<p className="text-sm text-muted-foreground">
|
|
159
|
+
Online payment is not set up here — contact the site administrator to change plan.
|
|
160
|
+
</p>
|
|
161
|
+
)}
|
|
162
|
+
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
163
|
+
{plans.map((plan) => (
|
|
164
|
+
<PlanCard
|
|
165
|
+
key={plan.id}
|
|
166
|
+
plan={plan}
|
|
167
|
+
interval={interval}
|
|
168
|
+
cta={planCta(status, plan, interval)}
|
|
169
|
+
canManage={can_manage}
|
|
170
|
+
busy={busy}
|
|
171
|
+
onChoose={choose}
|
|
172
|
+
/>
|
|
173
|
+
))}
|
|
174
|
+
</div>
|
|
175
|
+
</section>
|
|
176
|
+
</div>
|
|
177
|
+
</PageShell>
|
|
178
|
+
<ConfirmActionDialog
|
|
179
|
+
open={pending !== null}
|
|
180
|
+
onOpenChange={(open) => !open && setPending(null)}
|
|
181
|
+
tone="primary"
|
|
182
|
+
icon={ArrowRightLeft}
|
|
183
|
+
title={pending?.cta === 'downgrade' ? 'Downgrade to the free plan?' : 'Change plan?'}
|
|
184
|
+
description={
|
|
185
|
+
pending?.cta === 'downgrade'
|
|
186
|
+
? 'Your paid plan stays active until the end of the current period, then ends.'
|
|
187
|
+
: `You will be moved to ${pending?.plan.name} (${
|
|
188
|
+
pending ? priceLabel(pending.plan, interval) : ''
|
|
189
|
+
}). Stripe prorates the difference on your next invoice.`
|
|
190
|
+
}
|
|
191
|
+
confirmLabel={pending?.cta === 'downgrade' ? 'Downgrade' : 'Change plan'}
|
|
192
|
+
cancelLabel="Keep current plan"
|
|
193
|
+
busy={busy}
|
|
194
|
+
onConfirm={confirmChange}
|
|
195
|
+
/>
|
|
196
|
+
</>
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
Billing.layout = [AuthenticatedLayout];
|
|
201
|
+
export default Billing;
|