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
|
@@ -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'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
|