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