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,219 @@
1
+ import { Head, router, usePage } from '@inertiajs/react';
2
+ import { CopyableId } from '@simple-module-py/ui/components/CopyableId';
3
+ import { InlineBanner } from '@simple-module-py/ui/components/InlineBanner';
4
+ import { PageShell } from '@simple-module-py/ui/components/PageShell';
5
+ import { PasswordInput } from '@simple-module-py/ui/components/PasswordInput';
6
+ import { Button } from '@simple-module-py/ui/components/ui/button';
7
+ import {
8
+ Card,
9
+ CardContent,
10
+ CardDescription,
11
+ CardHeader,
12
+ CardTitle,
13
+ } from '@simple-module-py/ui/components/ui/card';
14
+ import { Checkbox } from '@simple-module-py/ui/components/ui/checkbox';
15
+ import { Input } from '@simple-module-py/ui/components/ui/input';
16
+ import { Label } from '@simple-module-py/ui/components/ui/label';
17
+ import { AdminLayout } from '@simple-module-py/ui/layouts/AdminLayout';
18
+ import { AlertTriangle, Info } from 'lucide-react';
19
+ import type React from 'react';
20
+ import { useState } from 'react';
21
+ import { toast } from 'sonner';
22
+ import { AdminNav } from '../components/AdminNav';
23
+ import { ADMIN_API, ApiError, api } from '../utils/api';
24
+ import type { AdminCommon, Connection as ConnectionInfo } from '../utils/types';
25
+
26
+ interface Props extends AdminCommon {
27
+ connection: ConnectionInfo;
28
+ webhook_url: string;
29
+ }
30
+
31
+ const RETURN_URL_MAX = 255;
32
+ const RETURN_URL_RULE =
33
+ 'Return URL must be an http(s) origin such as https://app.example.com — no path, query or fragment, at most 255 characters.';
34
+
35
+ /** Mirrors the server's `normalise_return_url`: blank is fine, else an http(s) origin. */
36
+ function returnUrlError(raw: string): string {
37
+ const value = raw.trim();
38
+ if (!value) return '';
39
+ if (value.length > RETURN_URL_MAX || /[\s?#@]/.test(value)) return RETURN_URL_RULE;
40
+ let url: URL;
41
+ try {
42
+ url = new URL(value);
43
+ } catch {
44
+ return RETURN_URL_RULE;
45
+ }
46
+ const httpish = url.protocol === 'http:' || url.protocol === 'https:';
47
+ return httpish && url.hostname && url.pathname === '/' ? '' : RETURN_URL_RULE;
48
+ }
49
+
50
+ function SecretField(props: {
51
+ id: string;
52
+ label: string;
53
+ stored: boolean;
54
+ value: string;
55
+ clear: boolean;
56
+ onValue: (v: string) => void;
57
+ onClear: (v: boolean) => void;
58
+ }) {
59
+ return (
60
+ <div className="space-y-1.5">
61
+ <Label htmlFor={props.id}>{props.label}</Label>
62
+ <PasswordInput
63
+ id={props.id}
64
+ autoComplete="off"
65
+ showLabel="Show"
66
+ hideLabel="Hide"
67
+ placeholder={props.stored ? 'Stored — leave blank to keep' : 'Not set'}
68
+ value={props.value}
69
+ disabled={props.clear}
70
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => props.onValue(e.target.value)}
71
+ />
72
+ {props.stored && (
73
+ <Label className="flex items-center gap-2 text-xs font-normal text-muted-foreground">
74
+ <Checkbox checked={props.clear} onCheckedChange={(v) => props.onClear(v === true)} />
75
+ Remove the stored value
76
+ </Label>
77
+ )}
78
+ </div>
79
+ );
80
+ }
81
+
82
+ function Connection() {
83
+ const { connection, webhook_url, csrf_token, can_manage } = usePage<{ props: Props }>()
84
+ .props as unknown as Props;
85
+ const [secret, setSecret] = useState('');
86
+ const [webhook, setWebhook] = useState('');
87
+ const [clearSecret, setClearSecret] = useState(false);
88
+ const [clearWebhook, setClearWebhook] = useState(false);
89
+ const [returnUrl, setReturnUrl] = useState(connection.return_base_url);
90
+ const [returnUrlMsg, setReturnUrlMsg] = useState('');
91
+ const [busy, setBusy] = useState(false);
92
+
93
+ async function save(e: React.FormEvent) {
94
+ e.preventDefault();
95
+ const urlError = returnUrlError(returnUrl);
96
+ setReturnUrlMsg(urlError);
97
+ if (urlError) return;
98
+ setBusy(true);
99
+ try {
100
+ await api(`${ADMIN_API}/connection`, csrf_token, {
101
+ method: 'PUT',
102
+ body: {
103
+ stripe_secret_key: secret,
104
+ stripe_webhook_secret: webhook,
105
+ clear_secret_key: clearSecret,
106
+ clear_webhook_secret: clearWebhook,
107
+ return_base_url: returnUrl,
108
+ },
109
+ });
110
+ toast.success('Stripe connection saved');
111
+ setSecret('');
112
+ setWebhook('');
113
+ router.reload();
114
+ } catch (err) {
115
+ if (err instanceof ApiError && err.detail === 'invalid_return_url') {
116
+ setReturnUrlMsg(String(err.body.message ?? RETURN_URL_RULE));
117
+ return;
118
+ }
119
+ toast.error((err as Error).message);
120
+ } finally {
121
+ setBusy(false);
122
+ }
123
+ }
124
+
125
+ const stripeChosen = connection.provider === 'stripe';
126
+ return (
127
+ <>
128
+ <Head title="Stripe connection" />
129
+ <PageShell title="Billing" description="How this site talks to Stripe.">
130
+ <AdminNav active="connection" />
131
+ <div className="max-w-2xl space-y-6">
132
+ {connection.provider_error && (
133
+ <InlineBanner
134
+ icon={AlertTriangle}
135
+ tone="warning"
136
+ title="Stripe is configured but not in use"
137
+ description={`${connection.provider_error}. Billing runs on the manual provider until this is fixed.`}
138
+ />
139
+ )}
140
+ {!stripeChosen && (
141
+ <InlineBanner
142
+ icon={Info}
143
+ title="The manual provider is active"
144
+ description="Set the billing provider to 'stripe' on the Settings screen and restart to take payments."
145
+ />
146
+ )}
147
+ <Card>
148
+ <CardHeader>
149
+ <CardTitle>Webhook endpoint</CardTitle>
150
+ <CardDescription>
151
+ Add this URL in Stripe → Developers → Webhooks, with the events
152
+ checkout.session.completed and customer.subscription.*.
153
+ </CardDescription>
154
+ </CardHeader>
155
+ <CardContent>
156
+ <CopyableId value={webhook_url} title="Copy webhook URL" />
157
+ </CardContent>
158
+ </Card>
159
+ <Card>
160
+ <CardHeader>
161
+ <CardTitle>Secrets</CardTitle>
162
+ <CardDescription>Stored encrypted with the app&apos;s secret key.</CardDescription>
163
+ </CardHeader>
164
+ <CardContent>
165
+ <form onSubmit={save} className="space-y-4">
166
+ <SecretField
167
+ id="stripe-secret"
168
+ label="Secret key (sk_…)"
169
+ stored={connection.has_secret_key}
170
+ value={secret}
171
+ clear={clearSecret}
172
+ onValue={setSecret}
173
+ onClear={setClearSecret}
174
+ />
175
+ <SecretField
176
+ id="stripe-webhook"
177
+ label="Webhook signing secret (whsec_…)"
178
+ stored={connection.has_webhook_secret}
179
+ value={webhook}
180
+ clear={clearWebhook}
181
+ onValue={setWebhook}
182
+ onClear={setClearWebhook}
183
+ />
184
+ <div className="space-y-1.5">
185
+ <Label htmlFor="return-url">Return URL origin</Label>
186
+ <Input
187
+ id="return-url"
188
+ placeholder="https://app.example.com"
189
+ maxLength={RETURN_URL_MAX}
190
+ value={returnUrl}
191
+ aria-invalid={returnUrlMsg ? true : undefined}
192
+ aria-describedby={returnUrlMsg ? 'return-url-error' : undefined}
193
+ onChange={(e) => {
194
+ setReturnUrl(e.target.value);
195
+ setReturnUrlMsg('');
196
+ }}
197
+ />
198
+ {returnUrlMsg && (
199
+ <p id="return-url-error" role="alert" className="text-xs text-destructive">
200
+ {returnUrlMsg}
201
+ </p>
202
+ )}
203
+ </div>
204
+ {can_manage && (
205
+ <Button type="submit" disabled={busy}>
206
+ Save connection
207
+ </Button>
208
+ )}
209
+ </form>
210
+ </CardContent>
211
+ </Card>
212
+ </div>
213
+ </PageShell>
214
+ </>
215
+ );
216
+ }
217
+
218
+ Connection.layout = [AdminLayout];
219
+ export default Connection;
@@ -0,0 +1,122 @@
1
+ import { Head, router, usePage } from '@inertiajs/react';
2
+ import { ConfirmActionDialog } from '@simple-module-py/ui/components/ConfirmActionDialog';
3
+ import { PageShell } from '@simple-module-py/ui/components/PageShell';
4
+ import { Button } from '@simple-module-py/ui/components/ui/button';
5
+ import { Card } from '@simple-module-py/ui/components/ui/card';
6
+ import { AdminLayout } from '@simple-module-py/ui/layouts/AdminLayout';
7
+ import { Archive, Plus } from 'lucide-react';
8
+ import { useRef, useState } from 'react';
9
+ import { toast } from 'sonner';
10
+ import { AdminNav } from '../components/AdminNav';
11
+ import { PlanEditor } from '../components/PlanEditor';
12
+ import { PlansTable } from '../components/PlansTable';
13
+ import { ADMIN_API, api } from '../utils/api';
14
+ import { type PlanForm, planPayload } from '../utils/plan-form';
15
+ import type { AdminCommon, Plan } from '../utils/types';
16
+
17
+ interface Props extends AdminCommon {
18
+ plans: Plan[];
19
+ known_limit_keys: string[];
20
+ }
21
+
22
+ function Plans() {
23
+ const { plans, known_limit_keys, csrf_token, can_manage } = usePage<{ props: Props }>()
24
+ .props as unknown as Props;
25
+ const [editing, setEditing] = useState<Plan | null>(null);
26
+ const [editorOpen, setEditorOpen] = useState(false);
27
+ const [archiving, setArchiving] = useState<Plan | null>(null);
28
+ const [busy, setBusy] = useState(false);
29
+ const [saveError, setSaveError] = useState<string | null>(null);
30
+ // Synchronous in-flight guard: `busy` state only lands on the next render,
31
+ // so a fast double click would otherwise send two POSTs.
32
+ const inFlight = useRef(false);
33
+
34
+ async function run(work: () => Promise<void>, onError?: (message: string) => void) {
35
+ if (inFlight.current) return;
36
+ inFlight.current = true;
37
+ setBusy(true);
38
+ try {
39
+ await work();
40
+ router.reload();
41
+ } catch (err) {
42
+ const message = (err as Error).message;
43
+ onError?.(message);
44
+ toast.error(message);
45
+ } finally {
46
+ inFlight.current = false;
47
+ setBusy(false);
48
+ }
49
+ }
50
+
51
+ const open = (plan: Plan | null) => {
52
+ setEditing(plan);
53
+ setSaveError(null);
54
+ setEditorOpen(true);
55
+ };
56
+
57
+ const save = (form: PlanForm) =>
58
+ run(async () => {
59
+ setSaveError(null);
60
+ const body = planPayload(form);
61
+ if (editing) {
62
+ await api(`${ADMIN_API}/plans/${editing.id}`, csrf_token, { method: 'PUT', body });
63
+ } else {
64
+ await api(`${ADMIN_API}/plans`, csrf_token, { body });
65
+ }
66
+ toast.success(editing ? 'Plan saved' : 'Plan created');
67
+ setEditorOpen(false);
68
+ }, setSaveError);
69
+
70
+ const archive = () =>
71
+ run(async () => {
72
+ if (!archiving) return;
73
+ await api(`${ADMIN_API}/plans/${archiving.id}/archive`, csrf_token, { body: {} });
74
+ toast.success(`${archiving.name} archived`);
75
+ setArchiving(null);
76
+ });
77
+
78
+ return (
79
+ <>
80
+ <Head title="Billing plans" />
81
+ <PageShell
82
+ title="Billing"
83
+ description="Plans decide each organisation's limits; Stripe prices decide what they pay."
84
+ actions={
85
+ can_manage ? (
86
+ <Button onClick={() => open(null)}>
87
+ <Plus aria-hidden="true" /> New plan
88
+ </Button>
89
+ ) : undefined
90
+ }
91
+ >
92
+ <AdminNav active="plans" />
93
+ <Card className="overflow-hidden p-0">
94
+ <PlansTable plans={plans} canManage={can_manage} onEdit={open} onArchive={setArchiving} />
95
+ </Card>
96
+ </PageShell>
97
+ <PlanEditor
98
+ open={editorOpen}
99
+ plan={editing}
100
+ knownKeys={known_limit_keys}
101
+ busy={busy}
102
+ serverError={saveError}
103
+ onOpenChange={setEditorOpen}
104
+ onSave={save}
105
+ />
106
+ <ConfirmActionDialog
107
+ open={archiving !== null}
108
+ onOpenChange={(o) => !o && setArchiving(null)}
109
+ icon={Archive}
110
+ title={`Archive ${archiving?.name ?? ''}?`}
111
+ description="Tenants already on it keep it; it disappears from the plan picker."
112
+ confirmLabel="Archive"
113
+ cancelLabel="Cancel"
114
+ busy={busy}
115
+ onConfirm={archive}
116
+ />
117
+ </>
118
+ );
119
+ }
120
+
121
+ Plans.layout = [AdminLayout];
122
+ export default Plans;
@@ -0,0 +1,123 @@
1
+ import { Head, router, usePage } from '@inertiajs/react';
2
+ import { EmptyState } from '@simple-module-py/ui/components/EmptyState';
3
+ import { PageShell } from '@simple-module-py/ui/components/PageShell';
4
+ import { Card } from '@simple-module-py/ui/components/ui/card';
5
+ import { Label } from '@simple-module-py/ui/components/ui/label';
6
+ import { NativeSelect, NativeSelectOption } from '@simple-module-py/ui/components/ui/native-select';
7
+ import { AdminLayout } from '@simple-module-py/ui/layouts/AdminLayout';
8
+ import { Building2 } from 'lucide-react';
9
+ import { useState } from 'react';
10
+ import { toast } from 'sonner';
11
+ import { AdminNav } from '../components/AdminNav';
12
+ import { AssignDialog } from '../components/AssignDialog';
13
+ import { SubscriptionsTable } from '../components/SubscriptionsTable';
14
+ import { ADMIN_API, api } from '../utils/api';
15
+ import { STATUS_LABEL } from '../utils/format';
16
+ import type { AdminCommon, Plan, SubscriptionRow, SubscriptionStatus } from '../utils/types';
17
+
18
+ interface Props extends AdminCommon {
19
+ rows: SubscriptionRow[];
20
+ plans: Plan[];
21
+ status_filter: string;
22
+ }
23
+
24
+ const VIEW_URL = '/admin/billing/subscriptions';
25
+
26
+ function Subscriptions() {
27
+ const { rows, plans, status_filter, csrf_token, can_manage, checkout_available, provider } =
28
+ usePage<{ props: Props }>().props as unknown as Props;
29
+ const [assigning, setAssigning] = useState<SubscriptionRow | null>(null);
30
+ const [busy, setBusy] = useState(false);
31
+
32
+ async function run(work: () => Promise<void>) {
33
+ setBusy(true);
34
+ try {
35
+ await work();
36
+ router.reload();
37
+ } catch (err) {
38
+ toast.error((err as Error).message);
39
+ } finally {
40
+ setBusy(false);
41
+ }
42
+ }
43
+
44
+ const resync = (row: SubscriptionRow) =>
45
+ run(async () => {
46
+ await api(`${ADMIN_API}/subscriptions/${row.tenant_id}/resync`, csrf_token, { body: {} });
47
+ toast.success(`${row.tenant_name} resynced from Stripe`);
48
+ });
49
+
50
+ const assign = (planId: number, status: SubscriptionStatus) =>
51
+ run(async () => {
52
+ if (!assigning) return;
53
+ await api(`${ADMIN_API}/subscriptions/${assigning.tenant_id}/assign`, csrf_token, {
54
+ body: { plan_id: planId, status },
55
+ });
56
+ toast.success(`Plan assigned to ${assigning.tenant_name}`);
57
+ setAssigning(null);
58
+ });
59
+
60
+ const filter = (value: string) =>
61
+ router.get(VIEW_URL, value ? { status: value } : {}, { preserveScroll: true });
62
+
63
+ return (
64
+ <>
65
+ <Head title="Billing subscriptions" />
66
+ <PageShell
67
+ title="Billing"
68
+ description={
69
+ checkout_available
70
+ ? 'Every organisation and its Stripe subscription.'
71
+ : `Provider: ${provider}. Assign plans by hand; nobody is charged.`
72
+ }
73
+ >
74
+ <AdminNav active="subscriptions" />
75
+ <div className="mb-4 flex max-w-xs items-center gap-2">
76
+ <Label htmlFor="status-filter" className="shrink-0">
77
+ Status
78
+ </Label>
79
+ <NativeSelect
80
+ id="status-filter"
81
+ value={status_filter}
82
+ onChange={(e) => filter(e.target.value)}
83
+ >
84
+ <NativeSelectOption value="">All</NativeSelectOption>
85
+ {(Object.keys(STATUS_LABEL) as SubscriptionStatus[]).map((s) => (
86
+ <NativeSelectOption key={s} value={s}>
87
+ {STATUS_LABEL[s]}
88
+ </NativeSelectOption>
89
+ ))}
90
+ </NativeSelect>
91
+ </div>
92
+ <Card className="overflow-hidden p-0">
93
+ {rows.length === 0 ? (
94
+ <EmptyState
95
+ icon={Building2}
96
+ title="No organisations"
97
+ description={status_filter ? 'None match this status.' : 'Nobody has signed up yet.'}
98
+ />
99
+ ) : (
100
+ <SubscriptionsTable
101
+ rows={rows}
102
+ canManage={can_manage}
103
+ mode={checkout_available ? 'resync' : 'assign'}
104
+ busy={busy}
105
+ onResync={resync}
106
+ onAssign={setAssigning}
107
+ />
108
+ )}
109
+ </Card>
110
+ </PageShell>
111
+ <AssignDialog
112
+ row={assigning}
113
+ plans={plans}
114
+ busy={busy}
115
+ onClose={() => setAssigning(null)}
116
+ onAssign={assign}
117
+ />
118
+ </>
119
+ );
120
+ }
121
+
122
+ Subscriptions.layout = [AdminLayout];
123
+ export default Subscriptions;
sm_billing/plans.py ADDED
@@ -0,0 +1,227 @@
1
+ """Plan CRUD and the invariants that keep entitlement resolution total.
2
+
3
+ Resolution always needs a default plan, so the service guarantees there is
4
+ exactly one, that it is free, and that it can be neither unset nor archived —
5
+ a new default is made by marking another free plan default. The session is
6
+ the caller's; nothing here commits.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ from datetime import UTC, datetime
13
+
14
+ from sqlalchemy import or_, select, update
15
+ from sqlalchemy.exc import IntegrityError
16
+ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
17
+
18
+ from sm_billing import constants as c
19
+ from sm_billing.models import Plan, Subscription
20
+ from sm_billing.schemas import PlanIn
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ class PlanError(Exception):
26
+ """A plan write that would break an invariant. ``code`` is the API detail."""
27
+
28
+ def __init__(self, code: str, status_code: int = 400) -> None:
29
+ super().__init__(code)
30
+ self.code = code
31
+ self.status_code = status_code
32
+
33
+
34
+ def _check(data: PlanIn) -> None:
35
+ has_price = bool(data.stripe_price_month or data.stripe_price_year)
36
+ if data.pricing_model == c.PricingModel.FREE:
37
+ if has_price:
38
+ raise PlanError("free_plan_has_price")
39
+ if data.trial_days:
40
+ raise PlanError("free_plan_has_trial")
41
+ elif not has_price:
42
+ raise PlanError("paid_plan_needs_price")
43
+ if data.is_default and data.pricing_model != c.PricingModel.FREE:
44
+ raise PlanError("default_must_be_free")
45
+
46
+
47
+ def _conflict_code(exc: IntegrityError) -> str:
48
+ """Which unique constraint fired, from the driver's message.
49
+
50
+ SQLite names the column (``billing_plan.stripe_price_month``); Postgres
51
+ names the constraint (``billing_plan_stripe_price_month_key``) — the key
52
+ column's is ``ix_billing_plan_key``.
53
+ """
54
+ return "price_taken" if "stripe_price" in str(exc.orig) else "plan_key_taken"
55
+
56
+
57
+ class PlanService:
58
+ def __init__(self, db: AsyncSession) -> None:
59
+ self.db = db
60
+
61
+ async def list(self, *, include_archived: bool = True) -> list[Plan]:
62
+ stmt = select(Plan).order_by(Plan.sort_order, Plan.id)
63
+ if not include_archived:
64
+ stmt = stmt.where(Plan.archived_at.is_(None))
65
+ return list((await self.db.execute(stmt)).scalars())
66
+
67
+ async def get(self, plan_id: int) -> Plan | None:
68
+ return await self.db.get(Plan, plan_id)
69
+
70
+ async def by_key(self, key: str) -> Plan | None:
71
+ return (await self.db.execute(select(Plan).where(Plan.key == key))).scalar_one_or_none()
72
+
73
+ async def by_price(self, price_id: str) -> tuple[Plan, str] | None:
74
+ stmt = select(Plan).where(
75
+ or_(Plan.stripe_price_month == price_id, Plan.stripe_price_year == price_id)
76
+ )
77
+ plan = (await self.db.execute(stmt)).scalar_one_or_none()
78
+ if plan is None:
79
+ return None
80
+ interval = c.Interval.MONTH if plan.stripe_price_month == price_id else c.Interval.YEAR
81
+ return plan, interval
82
+
83
+ async def default(self) -> Plan:
84
+ stmt = select(Plan).where(Plan.is_default.is_(True)).limit(1)
85
+ plan = (await self.db.execute(stmt)).scalar_one_or_none()
86
+ if plan is None: # pragma: no cover - startup seeds one and writes keep it
87
+ raise PlanError("no_default_plan", status_code=500)
88
+ return plan
89
+
90
+ async def _ensure_unique(self, data: PlanIn, plan_id: int | None) -> None:
91
+ existing = await self.by_key(data.key)
92
+ if existing is not None and existing.id != plan_id:
93
+ raise PlanError("plan_key_taken", status_code=409)
94
+ for price in (data.stripe_price_month, data.stripe_price_year):
95
+ if price is None:
96
+ continue
97
+ match = await self.by_price(price)
98
+ if match is not None and match[0].id != plan_id:
99
+ raise PlanError("price_taken", status_code=409)
100
+ if data.stripe_price_month and data.stripe_price_month == data.stripe_price_year:
101
+ raise PlanError("price_taken", status_code=409)
102
+
103
+ async def _flush_unique(self, data: PlanIn, plan_id: int | None, apply) -> None:
104
+ """Run ``apply`` and flush inside a savepoint; a unique clash is a 409.
105
+
106
+ ``_ensure_unique`` is check-then-write, so two concurrent requests can
107
+ both pass it. The constraint then fires at flush; roll back just the
108
+ savepoint (the request session stays usable) and report which one.
109
+ """
110
+ try:
111
+ async with self.db.begin_nested():
112
+ apply()
113
+ await self.db.flush()
114
+ except IntegrityError as exc:
115
+ await self._ensure_unique(data, plan_id)
116
+ raise PlanError(_conflict_code(exc), status_code=409) from exc
117
+
118
+ async def _take_default(self, plan_id: int) -> None:
119
+ await self.db.execute(
120
+ update(Plan)
121
+ .where(Plan.id != plan_id, Plan.is_default.is_(True))
122
+ .values(is_default=False)
123
+ )
124
+
125
+ async def create(self, data: PlanIn) -> Plan:
126
+ _check(data)
127
+ await self._ensure_unique(data, None)
128
+ plan = Plan(**data.model_dump())
129
+ await self._flush_unique(data, None, lambda: self.db.add(plan))
130
+ if plan.is_default:
131
+ await self._take_default(plan.id)
132
+ return plan
133
+
134
+ async def update(self, plan_id: int, data: PlanIn) -> Plan:
135
+ plan = await self.get(plan_id)
136
+ if plan is None:
137
+ raise PlanError("plan_not_found", status_code=404)
138
+ if data.key != plan.key:
139
+ raise PlanError("plan_key_immutable")
140
+ _check(data)
141
+ if plan.is_default and not data.is_default:
142
+ raise PlanError("default_required", status_code=409)
143
+ if data.is_default and plan.archived_at is not None:
144
+ raise PlanError("cannot_archive_default", status_code=409)
145
+ await self._guard_prices_in_use(plan, data)
146
+ await self._ensure_unique(data, plan.id)
147
+
148
+ def apply() -> None:
149
+ for field, value in data.model_dump().items():
150
+ setattr(plan, field, value)
151
+ plan.updated_at = datetime.now(UTC)
152
+
153
+ await self._flush_unique(data, plan.id, apply)
154
+ if plan.is_default:
155
+ await self._take_default(plan.id)
156
+ return plan
157
+
158
+ async def _guard_prices_in_use(self, plan: Plan, data: PlanIn) -> None:
159
+ """A price someone is subscribed on cannot be swapped or removed.
160
+
161
+ Webhooks map a subscription to its plan through the price; change it
162
+ under a live subscriber and every later event for them — including the
163
+ cancel or the "unpaid" — can no longer be mapped.
164
+ """
165
+ changes = (
166
+ (c.Interval.MONTH, plan.stripe_price_month, data.stripe_price_month),
167
+ (c.Interval.YEAR, plan.stripe_price_year, data.stripe_price_year),
168
+ )
169
+ for interval, old, new in changes:
170
+ if not old or old == new:
171
+ continue
172
+ stmt = (
173
+ select(Subscription.id)
174
+ .where(
175
+ Subscription.plan_id == plan.id,
176
+ Subscription.interval == interval,
177
+ Subscription.provider_subscription_id.is_not(None),
178
+ Subscription.status != c.SubscriptionStatus.CANCELED,
179
+ )
180
+ .limit(1)
181
+ )
182
+ if (await self.db.execute(stmt)).first() is not None:
183
+ raise PlanError("price_in_use", status_code=409)
184
+
185
+ async def archive(self, plan_id: int) -> Plan:
186
+ plan = await self.get(plan_id)
187
+ if plan is None:
188
+ raise PlanError("plan_not_found", status_code=404)
189
+ if plan.is_default:
190
+ raise PlanError("cannot_archive_default", status_code=409)
191
+ if plan.archived_at is None:
192
+ plan.archived_at = datetime.now(UTC)
193
+ await self.db.flush()
194
+ return plan
195
+
196
+
197
+ async def seed_default_plan(session_factory: async_sessionmaker) -> None:
198
+ """Make sure a free default plan exists. Idempotent; runs at every startup."""
199
+ async with session_factory() as session:
200
+ default = (
201
+ await session.execute(select(Plan).where(Plan.is_default.is_(True)).limit(1))
202
+ ).scalar_one_or_none()
203
+ if default is not None:
204
+ return
205
+ existing = (
206
+ await session.execute(select(Plan).where(Plan.key == c.DEFAULT_PLAN_KEY))
207
+ ).scalar_one_or_none()
208
+ if existing is not None and existing.pricing_model == c.PricingModel.FREE:
209
+ existing.is_default = True
210
+ existing.archived_at = None
211
+ else:
212
+ key = c.DEFAULT_PLAN_KEY if existing is None else f"{c.DEFAULT_PLAN_KEY}-default"
213
+ session.add(
214
+ Plan(
215
+ key=key,
216
+ name=c.DEFAULT_PLAN_NAME,
217
+ pricing_model=c.PricingModel.FREE,
218
+ is_default=True,
219
+ )
220
+ )
221
+ try:
222
+ await session.commit()
223
+ except IntegrityError:
224
+ # Another worker seeded it between our check and commit.
225
+ await session.rollback()
226
+ return
227
+ logger.info("billing: seeded the free default plan")
File without changes