simple-module-billing 0.0.8__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. simple_module_billing-0.0.8.dist-info/METADATA +211 -0
  2. simple_module_billing-0.0.8.dist-info/RECORD +64 -0
  3. simple_module_billing-0.0.8.dist-info/WHEEL +4 -0
  4. simple_module_billing-0.0.8.dist-info/entry_points.txt +5 -0
  5. sm_billing/__init__.py +0 -0
  6. sm_billing/admin.py +236 -0
  7. sm_billing/checkout.py +203 -0
  8. sm_billing/cli.py +44 -0
  9. sm_billing/components/AdminNav.tsx +44 -0
  10. sm_billing/components/AssignDialog.tsx +87 -0
  11. sm_billing/components/CurrentPlanCard.tsx +86 -0
  12. sm_billing/components/LimitRows.tsx +80 -0
  13. sm_billing/components/PlanCard.tsx +82 -0
  14. sm_billing/components/PlanEditor.tsx +213 -0
  15. sm_billing/components/PlansTable.tsx +78 -0
  16. sm_billing/components/StatusBadge.tsx +7 -0
  17. sm_billing/components/SubscriptionsTable.tsx +86 -0
  18. sm_billing/constants.py +112 -0
  19. sm_billing/context.py +68 -0
  20. sm_billing/contracts/__init__.py +0 -0
  21. sm_billing/contracts/provider.py +107 -0
  22. sm_billing/crypto.py +89 -0
  23. sm_billing/deps.py +67 -0
  24. sm_billing/endpoints/__init__.py +0 -0
  25. sm_billing/endpoints/admin_api.py +93 -0
  26. sm_billing/endpoints/admin_views.py +70 -0
  27. sm_billing/endpoints/api.py +61 -0
  28. sm_billing/endpoints/routers.py +26 -0
  29. sm_billing/endpoints/views.py +56 -0
  30. sm_billing/endpoints/webhooks.py +27 -0
  31. sm_billing/entitlements.py +30 -0
  32. sm_billing/errors.py +41 -0
  33. sm_billing/hooks/useCheckoutPolling.ts +50 -0
  34. sm_billing/lifecycle.py +74 -0
  35. sm_billing/models.py +112 -0
  36. sm_billing/module.py +132 -0
  37. sm_billing/package.json +17 -0
  38. sm_billing/pages/Billing.tsx +201 -0
  39. sm_billing/pages/Connection.tsx +219 -0
  40. sm_billing/pages/Plans.tsx +122 -0
  41. sm_billing/pages/Subscriptions.tsx +123 -0
  42. sm_billing/plans.py +227 -0
  43. sm_billing/providers/__init__.py +0 -0
  44. sm_billing/providers/factory.py +47 -0
  45. sm_billing/providers/manual.py +60 -0
  46. sm_billing/providers/stripe.py +209 -0
  47. sm_billing/reconcile.py +71 -0
  48. sm_billing/resolve.py +30 -0
  49. sm_billing/schemas.py +222 -0
  50. sm_billing/seats.py +63 -0
  51. sm_billing/services.py +36 -0
  52. sm_billing/settings.py +47 -0
  53. sm_billing/startup.py +27 -0
  54. sm_billing/sync.py +120 -0
  55. sm_billing/utils/api.test.ts +57 -0
  56. sm_billing/utils/api.ts +95 -0
  57. sm_billing/utils/format.test.ts +125 -0
  58. sm_billing/utils/format.ts +91 -0
  59. sm_billing/utils/plan-form.test.ts +180 -0
  60. sm_billing/utils/plan-form.ts +204 -0
  61. sm_billing/utils/seats.test.ts +22 -0
  62. sm_billing/utils/seats.ts +5 -0
  63. sm_billing/utils/types.ts +87 -0
  64. sm_billing/webhooks.py +128 -0
sm_billing/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
+ &ldquo;Unpaid&rdquo; 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 &amp; 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
+ }