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/checkout.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""What a tenant owner can do: see status, check out, open the portal, change plan.
|
|
2
|
+
|
|
3
|
+
Nothing here grants a plan. Checkout returns a Stripe URL and the webhook
|
|
4
|
+
writes the subscription; plan changes call the provider and then re-sync from
|
|
5
|
+
its answer, so the local row always mirrors what Stripe actually did.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import contextlib
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
from sm_billing import constants as c
|
|
14
|
+
from sm_billing.contracts.provider import ProviderError
|
|
15
|
+
from sm_billing.errors import BillingError
|
|
16
|
+
from sm_billing.lifecycle import tenant_service
|
|
17
|
+
from sm_billing.models import Customer, Plan, Subscription
|
|
18
|
+
from sm_billing.plans import PlanService
|
|
19
|
+
from sm_billing.resolve import effective_plan, subscription_for
|
|
20
|
+
from sm_billing.schemas import PlanOut, StatusOut, SubscriptionOut
|
|
21
|
+
from sm_billing.services import current_provider
|
|
22
|
+
from sm_billing.sync import resync
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from fastapi import FastAPI
|
|
26
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
27
|
+
|
|
28
|
+
from sm_billing.contracts.provider import BillingProvider
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def plan_out(plan: Plan) -> PlanOut:
|
|
32
|
+
return PlanOut.model_validate(plan, from_attributes=True)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def subscription_out(sub: Subscription | None) -> SubscriptionOut | None:
|
|
36
|
+
if sub is None:
|
|
37
|
+
return None
|
|
38
|
+
return SubscriptionOut(
|
|
39
|
+
status=sub.status,
|
|
40
|
+
interval=sub.interval,
|
|
41
|
+
quantity=sub.quantity,
|
|
42
|
+
trial_end=sub.trial_end,
|
|
43
|
+
current_period_end=sub.current_period_end,
|
|
44
|
+
cancel_at_period_end=sub.cancel_at_period_end,
|
|
45
|
+
has_provider_subscription=sub.provider_subscription_id is not None,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class BillingService:
|
|
50
|
+
def __init__(self, db: AsyncSession, app: FastAPI, tenant_id: str) -> None:
|
|
51
|
+
self.db = db
|
|
52
|
+
self.app = app
|
|
53
|
+
self.tenant_id = tenant_id
|
|
54
|
+
self.provider: BillingProvider = current_provider(app)
|
|
55
|
+
self.plans = PlanService(db)
|
|
56
|
+
self.tenants = tenant_service(db, app)
|
|
57
|
+
|
|
58
|
+
# ── reads ───────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
async def members(self) -> int:
|
|
61
|
+
return (await self.tenants.member_counts([self.tenant_id])).get(self.tenant_id, 0)
|
|
62
|
+
|
|
63
|
+
async def status(self) -> StatusOut:
|
|
64
|
+
plan, sub = await effective_plan(self.db, self.tenant_id)
|
|
65
|
+
customer = await self.db.get(Customer, self.tenant_id)
|
|
66
|
+
return StatusOut(
|
|
67
|
+
plan=plan_out(plan),
|
|
68
|
+
subscription=subscription_out(sub),
|
|
69
|
+
# Pending invitations hold a seat, as in the seat guard and Members page.
|
|
70
|
+
seats={
|
|
71
|
+
"used": await self.tenants.seats_used(self.tenant_id),
|
|
72
|
+
"limit": plan.limits.get(c.ENTITLEMENT_SEATS),
|
|
73
|
+
},
|
|
74
|
+
provider=self.provider.name,
|
|
75
|
+
checkout_available=self.provider.supports_checkout,
|
|
76
|
+
portal_available=self.provider.supports_checkout
|
|
77
|
+
and customer is not None
|
|
78
|
+
and customer.provider_customer_id is not None,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
async def public_plans(self) -> list[PlanOut]:
|
|
82
|
+
plans = await self.plans.list(include_archived=False)
|
|
83
|
+
return [plan_out(p) for p in plans if p.is_public]
|
|
84
|
+
|
|
85
|
+
# ── guards ──────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
def _require_checkout(self) -> None:
|
|
88
|
+
if not self.provider.supports_checkout:
|
|
89
|
+
raise BillingError("checkout_unavailable", 409)
|
|
90
|
+
|
|
91
|
+
async def _target(self, plan_id: int) -> Plan:
|
|
92
|
+
plan = await self.plans.get(plan_id)
|
|
93
|
+
if plan is None or plan.archived_at is not None or not plan.is_public:
|
|
94
|
+
raise BillingError("plan_not_found", 404)
|
|
95
|
+
return plan
|
|
96
|
+
|
|
97
|
+
@staticmethod
|
|
98
|
+
def _price(plan: Plan, interval: str) -> str:
|
|
99
|
+
price = plan.stripe_price_month if interval == c.Interval.MONTH else plan.stripe_price_year
|
|
100
|
+
if not price:
|
|
101
|
+
raise BillingError("interval_unavailable")
|
|
102
|
+
return price
|
|
103
|
+
|
|
104
|
+
async def _seat_guard(self, plan: Plan) -> None:
|
|
105
|
+
limit = plan.limits.get(c.ENTITLEMENT_SEATS)
|
|
106
|
+
used = await self.tenants.seats_used(self.tenant_id)
|
|
107
|
+
if limit is not None and used > limit:
|
|
108
|
+
raise BillingError("too_many_members", 409, used=used, limit=limit)
|
|
109
|
+
|
|
110
|
+
async def _quantity(self, plan: Plan) -> int:
|
|
111
|
+
if plan.pricing_model == c.PricingModel.PER_SEAT:
|
|
112
|
+
return max(1, await self.members())
|
|
113
|
+
return 1
|
|
114
|
+
|
|
115
|
+
async def _live_subscription(self) -> Subscription | None:
|
|
116
|
+
sub = await subscription_for(self.db, self.tenant_id)
|
|
117
|
+
if sub is None or sub.provider_subscription_id is None:
|
|
118
|
+
return None
|
|
119
|
+
return sub if sub.status in c.ENTITLED_STATUSES else None
|
|
120
|
+
|
|
121
|
+
# ── writes ──────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
async def checkout(self, plan_id: int, interval: str, *, email: str | None, base: str) -> str:
|
|
124
|
+
self._require_checkout()
|
|
125
|
+
plan = await self._target(plan_id)
|
|
126
|
+
if plan.pricing_model == c.PricingModel.FREE:
|
|
127
|
+
raise BillingError("plan_is_free")
|
|
128
|
+
price = self._price(plan, interval)
|
|
129
|
+
if await self._live_subscription() is not None:
|
|
130
|
+
raise BillingError("already_subscribed", 409)
|
|
131
|
+
await self._seat_guard(plan)
|
|
132
|
+
customer = await self._customer(email)
|
|
133
|
+
# The local row only changes when a webhook lands, so ask Stripe too:
|
|
134
|
+
# a subscription paid in another tab must not be joined by a second.
|
|
135
|
+
if await self.provider.live_subscription_ids(customer.provider_customer_id):
|
|
136
|
+
raise BillingError("already_subscribed", 409)
|
|
137
|
+
if customer.checkout_session_id:
|
|
138
|
+
# One open session per tenant: the older one can no longer be paid.
|
|
139
|
+
with contextlib.suppress(ProviderError): # already completed or expired
|
|
140
|
+
await self.provider.expire_checkout(customer.checkout_session_id)
|
|
141
|
+
session = await self.provider.create_checkout(
|
|
142
|
+
customer_id=customer.provider_customer_id,
|
|
143
|
+
price_id=price,
|
|
144
|
+
quantity=await self._quantity(plan),
|
|
145
|
+
trial_days=0 if await self._trial_used() else plan.trial_days,
|
|
146
|
+
tenant_id=self.tenant_id,
|
|
147
|
+
success_url=f"{base}{c.VIEW_PREFIX}/?checkout=success",
|
|
148
|
+
cancel_url=f"{base}{c.VIEW_PREFIX}/?checkout=cancel",
|
|
149
|
+
)
|
|
150
|
+
customer.checkout_session_id = session.id
|
|
151
|
+
await self.db.flush()
|
|
152
|
+
return session.url
|
|
153
|
+
|
|
154
|
+
async def _customer(self, email: str | None) -> Customer:
|
|
155
|
+
customer = await self.db.get(Customer, self.tenant_id)
|
|
156
|
+
if customer is not None and customer.provider_customer_id is not None:
|
|
157
|
+
return customer
|
|
158
|
+
tenant = await self.tenants.get(self.tenant_id)
|
|
159
|
+
customer_id = await self.provider.ensure_customer(
|
|
160
|
+
self.tenant_id, tenant.name if tenant else self.tenant_id, email
|
|
161
|
+
)
|
|
162
|
+
if customer is None:
|
|
163
|
+
customer = Customer(tenant_id=self.tenant_id, provider=self.provider.name)
|
|
164
|
+
self.db.add(customer)
|
|
165
|
+
customer.provider_customer_id = customer_id
|
|
166
|
+
customer.provider = self.provider.name
|
|
167
|
+
customer.email = email
|
|
168
|
+
await self.db.flush()
|
|
169
|
+
return customer
|
|
170
|
+
|
|
171
|
+
async def _trial_used(self) -> bool:
|
|
172
|
+
"""One trial per organisation: any earlier provider subscription spent it."""
|
|
173
|
+
sub = await subscription_for(self.db, self.tenant_id)
|
|
174
|
+
return sub is not None and (
|
|
175
|
+
sub.provider_subscription_id is not None or sub.trial_end is not None
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
async def portal(self, *, base: str) -> str:
|
|
179
|
+
self._require_checkout()
|
|
180
|
+
customer = await self.db.get(Customer, self.tenant_id)
|
|
181
|
+
if customer is None or customer.provider_customer_id is None:
|
|
182
|
+
raise BillingError("no_customer", 409)
|
|
183
|
+
return await self.provider.portal_url(
|
|
184
|
+
customer.provider_customer_id, f"{base}{c.VIEW_PREFIX}/"
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
async def change_plan(self, plan_id: int, interval: str) -> StatusOut:
|
|
188
|
+
self._require_checkout()
|
|
189
|
+
plan = await self._target(plan_id)
|
|
190
|
+
sub = await self._live_subscription()
|
|
191
|
+
if sub is None:
|
|
192
|
+
raise BillingError("no_subscription", 409)
|
|
193
|
+
await self._seat_guard(plan)
|
|
194
|
+
subscription_id = sub.provider_subscription_id
|
|
195
|
+
if plan.pricing_model == c.PricingModel.FREE:
|
|
196
|
+
await self.provider.cancel_at_period_end(subscription_id)
|
|
197
|
+
else:
|
|
198
|
+
price = self._price(plan, interval)
|
|
199
|
+
if sub.plan_id == plan.id and sub.interval == interval and not sub.cancel_at_period_end:
|
|
200
|
+
raise BillingError("already_on_plan", 409)
|
|
201
|
+
await self.provider.change_plan(subscription_id, price, await self._quantity(plan))
|
|
202
|
+
await resync(self.db, self.app, self.provider, subscription_id)
|
|
203
|
+
return await self.status()
|
sm_billing/cli.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""``smpy billing reconcile [--tenant ID]`` — mounted through the framework CLI seam.
|
|
2
|
+
|
|
3
|
+
Run from the repo root like every other entry point, so the host's settings
|
|
4
|
+
(database URL, secret key) are the ones the app boots with.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
|
|
13
|
+
app = typer.Typer(help="Billing module administration.", no_args_is_help=True)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@app.callback()
|
|
17
|
+
def _main() -> None:
|
|
18
|
+
"""Billing module administration."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@app.command("reconcile")
|
|
22
|
+
def reconcile_command(
|
|
23
|
+
tenant: str | None = typer.Option(None, "--tenant", help="Only this tenant id."),
|
|
24
|
+
) -> None:
|
|
25
|
+
"""Re-fetch provider subscriptions and push drifted seat quantities."""
|
|
26
|
+
from simple_module_hosting.app_builder import create_app
|
|
27
|
+
from simple_module_hosting.settings import Settings
|
|
28
|
+
|
|
29
|
+
from sm_billing.reconcile import reconcile
|
|
30
|
+
|
|
31
|
+
fastapi_app = create_app(Settings())
|
|
32
|
+
|
|
33
|
+
async def run() -> int:
|
|
34
|
+
async with fastapi_app.router.lifespan_context(fastapi_app):
|
|
35
|
+
report = await reconcile(fastapi_app, tenant_id=tenant)
|
|
36
|
+
if report.skipped:
|
|
37
|
+
typer.echo(f"Nothing to reconcile: {report.skipped}.")
|
|
38
|
+
return 0
|
|
39
|
+
typer.echo(f"Synced {report.synced}, pushed {report.pushed} quantity change(s).")
|
|
40
|
+
for tenant_id, error in report.errors:
|
|
41
|
+
typer.echo(f" {tenant_id}: {error}", err=True)
|
|
42
|
+
return 1 if report.errors else 0
|
|
43
|
+
|
|
44
|
+
raise typer.Exit(code=asyncio.run(run()))
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { Link } from '@inertiajs/react';
|
|
2
|
+
import { Toaster } from '@simple-module-py/ui/components/ui/sonner';
|
|
3
|
+
import { cn } from '@simple-module-py/ui/lib/utils';
|
|
4
|
+
|
|
5
|
+
const TABS = [
|
|
6
|
+
{ key: 'subscriptions', label: 'Subscriptions', href: '/admin/billing/subscriptions' },
|
|
7
|
+
{ key: 'plans', label: 'Plans', href: '/admin/billing/plans' },
|
|
8
|
+
{ key: 'connection', label: 'Stripe connection', href: '/admin/billing/connection' },
|
|
9
|
+
] as const;
|
|
10
|
+
|
|
11
|
+
export type AdminTab = (typeof TABS)[number]['key'];
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The three billing admin screens, as tabs — and their toast outlet.
|
|
15
|
+
*
|
|
16
|
+
* ``AdminLayout`` (unlike ``AuthenticatedLayout``) mounts no ``<Toaster/>``,
|
|
17
|
+
* so without this every save confirmation and every API error on these
|
|
18
|
+
* screens would be raised into nothing. Every billing admin page renders this
|
|
19
|
+
* nav, which makes it the one place that guarantees the outlet exists.
|
|
20
|
+
*/
|
|
21
|
+
export function AdminNav({ active }: { active: AdminTab }) {
|
|
22
|
+
return (
|
|
23
|
+
<>
|
|
24
|
+
<nav aria-label="Billing administration" className="mb-4 flex gap-1 border-b">
|
|
25
|
+
{TABS.map((tab) => (
|
|
26
|
+
<Link
|
|
27
|
+
key={tab.key}
|
|
28
|
+
href={tab.href}
|
|
29
|
+
aria-current={tab.key === active ? 'page' : undefined}
|
|
30
|
+
className={cn(
|
|
31
|
+
'-mb-px border-b-2 px-3 py-2 text-sm font-medium transition-colors max-lg:min-h-11',
|
|
32
|
+
tab.key === active
|
|
33
|
+
? 'border-primary text-foreground'
|
|
34
|
+
: 'border-transparent text-muted-foreground hover:text-foreground',
|
|
35
|
+
)}
|
|
36
|
+
>
|
|
37
|
+
{tab.label}
|
|
38
|
+
</Link>
|
|
39
|
+
))}
|
|
40
|
+
</nav>
|
|
41
|
+
<Toaster richColors position="top-right" />
|
|
42
|
+
</>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { Button } from '@simple-module-py/ui/components/ui/button';
|
|
2
|
+
import {
|
|
3
|
+
Dialog,
|
|
4
|
+
DialogContent,
|
|
5
|
+
DialogDescription,
|
|
6
|
+
DialogFooter,
|
|
7
|
+
DialogHeader,
|
|
8
|
+
DialogTitle,
|
|
9
|
+
} from '@simple-module-py/ui/components/ui/dialog';
|
|
10
|
+
import { Label } from '@simple-module-py/ui/components/ui/label';
|
|
11
|
+
import { NativeSelect, NativeSelectOption } from '@simple-module-py/ui/components/ui/native-select';
|
|
12
|
+
import { useEffect, useState } from 'react';
|
|
13
|
+
import { STATUS_LABEL } from '../utils/format';
|
|
14
|
+
import type { Plan, SubscriptionRow, SubscriptionStatus } from '../utils/types';
|
|
15
|
+
|
|
16
|
+
interface Props {
|
|
17
|
+
row: SubscriptionRow | null;
|
|
18
|
+
plans: Plan[];
|
|
19
|
+
busy: boolean;
|
|
20
|
+
onClose: () => void;
|
|
21
|
+
onAssign: (planId: number, status: SubscriptionStatus) => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Manual provider: set an organisation's plan and status by hand. */
|
|
25
|
+
export function AssignDialog({ row, plans, busy, onClose, onAssign }: Props) {
|
|
26
|
+
const choices = plans.filter((p) => !p.archived_at);
|
|
27
|
+
const [planId, setPlanId] = useState<number>(row?.plan_id ?? choices[0]?.id ?? 0);
|
|
28
|
+
const [status, setStatus] = useState<SubscriptionStatus>(row?.status ?? 'active');
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
if (row) {
|
|
31
|
+
setPlanId(row.plan_id);
|
|
32
|
+
setStatus(row.status ?? 'active');
|
|
33
|
+
}
|
|
34
|
+
}, [row]);
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<Dialog open={row !== null} onOpenChange={(open) => !open && onClose()}>
|
|
38
|
+
<DialogContent>
|
|
39
|
+
<DialogHeader>
|
|
40
|
+
<DialogTitle>Assign a plan to {row?.tenant_name}</DialogTitle>
|
|
41
|
+
<DialogDescription>
|
|
42
|
+
“Unpaid” suspends the organisation; any other status lifts a suspension
|
|
43
|
+
billing made.
|
|
44
|
+
</DialogDescription>
|
|
45
|
+
</DialogHeader>
|
|
46
|
+
<div className="grid gap-4 sm:grid-cols-2">
|
|
47
|
+
<div className="space-y-1.5">
|
|
48
|
+
<Label htmlFor="assign-plan">Plan</Label>
|
|
49
|
+
<NativeSelect
|
|
50
|
+
id="assign-plan"
|
|
51
|
+
value={String(planId)}
|
|
52
|
+
onChange={(e) => setPlanId(Number(e.target.value))}
|
|
53
|
+
>
|
|
54
|
+
{choices.map((p) => (
|
|
55
|
+
<NativeSelectOption key={p.id} value={String(p.id)}>
|
|
56
|
+
{p.name}
|
|
57
|
+
</NativeSelectOption>
|
|
58
|
+
))}
|
|
59
|
+
</NativeSelect>
|
|
60
|
+
</div>
|
|
61
|
+
<div className="space-y-1.5">
|
|
62
|
+
<Label htmlFor="assign-status">Status</Label>
|
|
63
|
+
<NativeSelect
|
|
64
|
+
id="assign-status"
|
|
65
|
+
value={status}
|
|
66
|
+
onChange={(e) => setStatus(e.target.value as SubscriptionStatus)}
|
|
67
|
+
>
|
|
68
|
+
{(Object.keys(STATUS_LABEL) as SubscriptionStatus[]).map((s) => (
|
|
69
|
+
<NativeSelectOption key={s} value={s}>
|
|
70
|
+
{STATUS_LABEL[s]}
|
|
71
|
+
</NativeSelectOption>
|
|
72
|
+
))}
|
|
73
|
+
</NativeSelect>
|
|
74
|
+
</div>
|
|
75
|
+
</div>
|
|
76
|
+
<DialogFooter>
|
|
77
|
+
<Button variant="outline" onClick={onClose}>
|
|
78
|
+
Cancel
|
|
79
|
+
</Button>
|
|
80
|
+
<Button disabled={busy || !planId} onClick={() => onAssign(planId, status)}>
|
|
81
|
+
Assign
|
|
82
|
+
</Button>
|
|
83
|
+
</DialogFooter>
|
|
84
|
+
</DialogContent>
|
|
85
|
+
</Dialog>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { InlineBanner } from '@simple-module-py/ui/components/InlineBanner';
|
|
2
|
+
import { Button } from '@simple-module-py/ui/components/ui/button';
|
|
3
|
+
import {
|
|
4
|
+
Card,
|
|
5
|
+
CardContent,
|
|
6
|
+
CardDescription,
|
|
7
|
+
CardHeader,
|
|
8
|
+
CardTitle,
|
|
9
|
+
} from '@simple-module-py/ui/components/ui/card';
|
|
10
|
+
import { AlertTriangle, CreditCard } from 'lucide-react';
|
|
11
|
+
import { formatDate } from '../utils/format';
|
|
12
|
+
import { seatsLabel } from '../utils/seats';
|
|
13
|
+
import type { Status } from '../utils/types';
|
|
14
|
+
import { StatusBadge } from './StatusBadge';
|
|
15
|
+
|
|
16
|
+
interface Props {
|
|
17
|
+
status: Status;
|
|
18
|
+
canManage: boolean;
|
|
19
|
+
busy: boolean;
|
|
20
|
+
onPortal: () => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The plan in force, its renewal or end date, seats, and the payment-trouble banner. */
|
|
24
|
+
export function CurrentPlanCard({ status, canManage, busy, onPortal }: Props) {
|
|
25
|
+
const sub = status.subscription;
|
|
26
|
+
const { used, limit } = status.seats;
|
|
27
|
+
const seats = seatsLabel(used, limit);
|
|
28
|
+
const portal = canManage && status.portal_available;
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<div className="space-y-4">
|
|
32
|
+
{sub?.status === 'past_due' && (
|
|
33
|
+
<InlineBanner
|
|
34
|
+
icon={AlertTriangle}
|
|
35
|
+
tone="warning"
|
|
36
|
+
title="Your last payment failed"
|
|
37
|
+
description="Stripe is retrying the charge. Update your payment method to keep your plan."
|
|
38
|
+
action={
|
|
39
|
+
portal ? (
|
|
40
|
+
<Button size="sm" onClick={onPortal} disabled={busy}>
|
|
41
|
+
Update payment method
|
|
42
|
+
</Button>
|
|
43
|
+
) : undefined
|
|
44
|
+
}
|
|
45
|
+
/>
|
|
46
|
+
)}
|
|
47
|
+
<Card>
|
|
48
|
+
<CardHeader>
|
|
49
|
+
<CardTitle className="flex flex-wrap items-center gap-2">
|
|
50
|
+
{status.plan.name}
|
|
51
|
+
{sub && <StatusBadge status={sub.status} />}
|
|
52
|
+
</CardTitle>
|
|
53
|
+
<CardDescription>{status.plan.description || 'Your current plan.'}</CardDescription>
|
|
54
|
+
</CardHeader>
|
|
55
|
+
<CardContent className="flex flex-wrap items-end justify-between gap-4">
|
|
56
|
+
<dl className="grid gap-1 text-sm">
|
|
57
|
+
<div className="flex gap-2">
|
|
58
|
+
<dt className="text-muted-foreground">Seats</dt>
|
|
59
|
+
<dd>{seats}</dd>
|
|
60
|
+
</div>
|
|
61
|
+
{sub?.status === 'trialing' && sub.trial_end && (
|
|
62
|
+
<div className="flex gap-2">
|
|
63
|
+
<dt className="text-muted-foreground">Trial ends</dt>
|
|
64
|
+
<dd>{formatDate(sub.trial_end)}</dd>
|
|
65
|
+
</div>
|
|
66
|
+
)}
|
|
67
|
+
{sub?.current_period_end && (
|
|
68
|
+
<div className="flex gap-2">
|
|
69
|
+
<dt className="text-muted-foreground">
|
|
70
|
+
{sub.cancel_at_period_end ? 'Ends on' : 'Renews on'}
|
|
71
|
+
</dt>
|
|
72
|
+
<dd>{formatDate(sub.current_period_end)}</dd>
|
|
73
|
+
</div>
|
|
74
|
+
)}
|
|
75
|
+
</dl>
|
|
76
|
+
{portal && (
|
|
77
|
+
<Button variant="outline" onClick={onPortal} disabled={busy}>
|
|
78
|
+
<CreditCard aria-hidden="true" />
|
|
79
|
+
Manage payment & invoices
|
|
80
|
+
</Button>
|
|
81
|
+
)}
|
|
82
|
+
</CardContent>
|
|
83
|
+
</Card>
|
|
84
|
+
</div>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { Button } from '@simple-module-py/ui/components/ui/button';
|
|
2
|
+
import { Input } from '@simple-module-py/ui/components/ui/input';
|
|
3
|
+
import { Plus, X } from 'lucide-react';
|
|
4
|
+
|
|
5
|
+
interface Row {
|
|
6
|
+
key: string;
|
|
7
|
+
value: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface Props {
|
|
11
|
+
rows: Row[];
|
|
12
|
+
knownKeys: string[];
|
|
13
|
+
onChange: (rows: Row[]) => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Key/number rows for a plan's limits. An absent key is unlimited; 0 forbids. */
|
|
17
|
+
export function LimitRows({ rows, knownKeys, onChange }: Props) {
|
|
18
|
+
const update = (index: number, patch: Partial<Row>) =>
|
|
19
|
+
onChange(rows.map((row, i) => (i === index ? { ...row, ...patch } : row)));
|
|
20
|
+
const missing = knownKeys.filter((key) => !rows.some((row) => row.key === key));
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<div className="space-y-2">
|
|
24
|
+
{rows.map((row, index) => (
|
|
25
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: rows have no identity until saved
|
|
26
|
+
<div key={index} className="flex items-center gap-2">
|
|
27
|
+
<Input
|
|
28
|
+
aria-label="Limit key"
|
|
29
|
+
placeholder="tenants.seats"
|
|
30
|
+
value={row.key}
|
|
31
|
+
onChange={(e) => update(index, { key: e.target.value })}
|
|
32
|
+
list="billing-known-limit-keys"
|
|
33
|
+
/>
|
|
34
|
+
<Input
|
|
35
|
+
aria-label={`Limit for ${row.key || 'new key'}`}
|
|
36
|
+
className="w-28"
|
|
37
|
+
inputMode="numeric"
|
|
38
|
+
value={row.value}
|
|
39
|
+
onChange={(e) => update(index, { value: e.target.value })}
|
|
40
|
+
/>
|
|
41
|
+
<Button
|
|
42
|
+
type="button"
|
|
43
|
+
variant="ghost"
|
|
44
|
+
size="icon"
|
|
45
|
+
aria-label={`Remove ${row.key || 'row'}`}
|
|
46
|
+
onClick={() => onChange(rows.filter((_, i) => i !== index))}
|
|
47
|
+
>
|
|
48
|
+
<X aria-hidden="true" />
|
|
49
|
+
</Button>
|
|
50
|
+
</div>
|
|
51
|
+
))}
|
|
52
|
+
<datalist id="billing-known-limit-keys">
|
|
53
|
+
{knownKeys.map((key) => (
|
|
54
|
+
<option key={key} value={key} />
|
|
55
|
+
))}
|
|
56
|
+
</datalist>
|
|
57
|
+
<div className="flex flex-wrap gap-2">
|
|
58
|
+
<Button
|
|
59
|
+
type="button"
|
|
60
|
+
variant="outline"
|
|
61
|
+
size="sm"
|
|
62
|
+
onClick={() => onChange([...rows, { key: '', value: '' }])}
|
|
63
|
+
>
|
|
64
|
+
<Plus aria-hidden="true" /> Add limit
|
|
65
|
+
</Button>
|
|
66
|
+
{missing.map((key) => (
|
|
67
|
+
<Button
|
|
68
|
+
key={key}
|
|
69
|
+
type="button"
|
|
70
|
+
variant="ghost"
|
|
71
|
+
size="sm"
|
|
72
|
+
onClick={() => onChange([...rows, { key, value: '' }])}
|
|
73
|
+
>
|
|
74
|
+
<Plus aria-hidden="true" /> {key}
|
|
75
|
+
</Button>
|
|
76
|
+
))}
|
|
77
|
+
</div>
|
|
78
|
+
</div>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { Badge } from '@simple-module-py/ui/components/ui/badge';
|
|
2
|
+
import { Button } from '@simple-module-py/ui/components/ui/button';
|
|
3
|
+
import {
|
|
4
|
+
Card,
|
|
5
|
+
CardContent,
|
|
6
|
+
CardDescription,
|
|
7
|
+
CardFooter,
|
|
8
|
+
CardHeader,
|
|
9
|
+
CardTitle,
|
|
10
|
+
} from '@simple-module-py/ui/components/ui/card';
|
|
11
|
+
import { cn } from '@simple-module-py/ui/lib/utils';
|
|
12
|
+
import { Check } from 'lucide-react';
|
|
13
|
+
import { type PlanCta, priceLabel } from '../utils/format';
|
|
14
|
+
import type { Interval, Plan } from '../utils/types';
|
|
15
|
+
|
|
16
|
+
interface Props {
|
|
17
|
+
plan: Plan;
|
|
18
|
+
interval: Interval;
|
|
19
|
+
cta: PlanCta;
|
|
20
|
+
canManage: boolean;
|
|
21
|
+
busy: boolean;
|
|
22
|
+
onChoose: (plan: Plan, cta: PlanCta) => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const LABEL: Record<PlanCta, string> = {
|
|
26
|
+
current: 'Current plan',
|
|
27
|
+
checkout: 'Subscribe',
|
|
28
|
+
change: 'Switch to this plan',
|
|
29
|
+
downgrade: 'Downgrade at period end',
|
|
30
|
+
unavailable: 'Not available',
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function limitLines(plan: Plan): string[] {
|
|
34
|
+
const seats = plan.limits['tenants.seats'];
|
|
35
|
+
const lines = [seats === undefined ? 'Unlimited seats' : `Up to ${seats} seats`];
|
|
36
|
+
for (const [key, value] of Object.entries(plan.limits)) {
|
|
37
|
+
if (key !== 'tenants.seats') lines.push(`${key}: ${value}`);
|
|
38
|
+
}
|
|
39
|
+
return lines;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function PlanCard({ plan, interval, cta, canManage, busy, onChoose }: Props) {
|
|
43
|
+
const actionable = canManage && cta !== 'current' && cta !== 'unavailable';
|
|
44
|
+
return (
|
|
45
|
+
<Card
|
|
46
|
+
className={cn('flex flex-col', cta === 'current' && 'border-primary ring-1 ring-primary')}
|
|
47
|
+
>
|
|
48
|
+
<CardHeader>
|
|
49
|
+
<CardTitle className="flex items-center justify-between gap-2">
|
|
50
|
+
{plan.name}
|
|
51
|
+
{plan.trial_days > 0 && cta === 'checkout' && (
|
|
52
|
+
<Badge variant="secondary">{plan.trial_days}-day trial</Badge>
|
|
53
|
+
)}
|
|
54
|
+
</CardTitle>
|
|
55
|
+
<CardDescription className="text-base font-medium text-foreground">
|
|
56
|
+
{priceLabel(plan, interval)}
|
|
57
|
+
</CardDescription>
|
|
58
|
+
</CardHeader>
|
|
59
|
+
<CardContent className="flex-1 space-y-3 text-sm">
|
|
60
|
+
{plan.description && <p className="text-muted-foreground">{plan.description}</p>}
|
|
61
|
+
<ul className="space-y-1.5">
|
|
62
|
+
{[...limitLines(plan), ...plan.features].map((line) => (
|
|
63
|
+
<li key={line} className="flex items-start gap-2">
|
|
64
|
+
<Check className="mt-0.5 size-4 shrink-0 text-primary" aria-hidden="true" />
|
|
65
|
+
<span>{line}</span>
|
|
66
|
+
</li>
|
|
67
|
+
))}
|
|
68
|
+
</ul>
|
|
69
|
+
</CardContent>
|
|
70
|
+
<CardFooter>
|
|
71
|
+
<Button
|
|
72
|
+
className="w-full max-lg:min-h-11"
|
|
73
|
+
variant={cta === 'downgrade' ? 'outline' : 'default'}
|
|
74
|
+
disabled={!actionable || busy}
|
|
75
|
+
onClick={() => onChoose(plan, cta)}
|
|
76
|
+
>
|
|
77
|
+
{LABEL[cta]}
|
|
78
|
+
</Button>
|
|
79
|
+
</CardFooter>
|
|
80
|
+
</Card>
|
|
81
|
+
);
|
|
82
|
+
}
|