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,213 @@
|
|
|
1
|
+
import { Button } from '@simple-module-py/ui/components/ui/button';
|
|
2
|
+
import { Checkbox } from '@simple-module-py/ui/components/ui/checkbox';
|
|
3
|
+
import {
|
|
4
|
+
Dialog,
|
|
5
|
+
DialogContent,
|
|
6
|
+
DialogDescription,
|
|
7
|
+
DialogFooter,
|
|
8
|
+
DialogHeader,
|
|
9
|
+
DialogTitle,
|
|
10
|
+
} from '@simple-module-py/ui/components/ui/dialog';
|
|
11
|
+
import { Input } from '@simple-module-py/ui/components/ui/input';
|
|
12
|
+
import { Label } from '@simple-module-py/ui/components/ui/label';
|
|
13
|
+
import { NativeSelect, NativeSelectOption } from '@simple-module-py/ui/components/ui/native-select';
|
|
14
|
+
import { Textarea } from '@simple-module-py/ui/components/ui/textarea';
|
|
15
|
+
import type React from 'react';
|
|
16
|
+
import { useEffect, useState } from 'react';
|
|
17
|
+
import {
|
|
18
|
+
emptyPlanForm,
|
|
19
|
+
formFromPlan,
|
|
20
|
+
MAX_DESCRIPTION,
|
|
21
|
+
MAX_NAME,
|
|
22
|
+
type PlanForm,
|
|
23
|
+
validatePlanForm,
|
|
24
|
+
withPricingModel,
|
|
25
|
+
} from '../utils/plan-form';
|
|
26
|
+
import type { Plan, PricingModel } from '../utils/types';
|
|
27
|
+
import { LimitRows } from './LimitRows';
|
|
28
|
+
|
|
29
|
+
interface Props {
|
|
30
|
+
open: boolean;
|
|
31
|
+
plan: Plan | null;
|
|
32
|
+
knownKeys: string[];
|
|
33
|
+
busy: boolean;
|
|
34
|
+
/** A rejection from the API, shown in the dialog's alert area. */
|
|
35
|
+
serverError?: string | null;
|
|
36
|
+
onOpenChange: (open: boolean) => void;
|
|
37
|
+
onSave: (form: PlanForm) => void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function Field({ id, label, children }: { id: string; label: string; children: React.ReactNode }) {
|
|
41
|
+
return (
|
|
42
|
+
<div className="space-y-1.5">
|
|
43
|
+
<Label htmlFor={id}>{label}</Label>
|
|
44
|
+
{children}
|
|
45
|
+
</div>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function PlanEditor({
|
|
50
|
+
open,
|
|
51
|
+
plan,
|
|
52
|
+
knownKeys,
|
|
53
|
+
busy,
|
|
54
|
+
serverError = null,
|
|
55
|
+
onOpenChange,
|
|
56
|
+
onSave,
|
|
57
|
+
}: Props) {
|
|
58
|
+
const [form, setForm] = useState<PlanForm>(emptyPlanForm());
|
|
59
|
+
const [error, setError] = useState<string | null>(null);
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
if (open) {
|
|
62
|
+
setForm(plan ? formFromPlan(plan) : emptyPlanForm());
|
|
63
|
+
setError(null);
|
|
64
|
+
}
|
|
65
|
+
}, [open, plan]);
|
|
66
|
+
|
|
67
|
+
const set = <K extends keyof PlanForm>(key: K, value: PlanForm[K]) =>
|
|
68
|
+
setForm((f) => ({ ...f, [key]: value }));
|
|
69
|
+
const text = (key: keyof PlanForm) => (e: React.ChangeEvent<HTMLInputElement>) =>
|
|
70
|
+
set(key, e.target.value as never);
|
|
71
|
+
const paid = form.pricing_model !== 'free';
|
|
72
|
+
|
|
73
|
+
function submit(e: React.FormEvent) {
|
|
74
|
+
e.preventDefault();
|
|
75
|
+
if (busy) return;
|
|
76
|
+
const problem = validatePlanForm(form);
|
|
77
|
+
setError(problem);
|
|
78
|
+
if (!problem) onSave(form);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
83
|
+
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
|
|
84
|
+
<form onSubmit={submit} className="space-y-5">
|
|
85
|
+
<DialogHeader>
|
|
86
|
+
<DialogTitle>{plan ? `Edit ${plan.name}` : 'New plan'}</DialogTitle>
|
|
87
|
+
<DialogDescription>
|
|
88
|
+
Limits and features decide what a tenant may do; Stripe prices decide what they pay.
|
|
89
|
+
</DialogDescription>
|
|
90
|
+
</DialogHeader>
|
|
91
|
+
<div className="grid gap-4 sm:grid-cols-2">
|
|
92
|
+
<Field id="plan-key" label="Key">
|
|
93
|
+
<Input id="plan-key" value={form.key} onChange={text('key')} disabled={!!plan} />
|
|
94
|
+
</Field>
|
|
95
|
+
<Field id="plan-name" label="Name">
|
|
96
|
+
<Input
|
|
97
|
+
id="plan-name"
|
|
98
|
+
value={form.name}
|
|
99
|
+
maxLength={MAX_NAME}
|
|
100
|
+
onChange={text('name')}
|
|
101
|
+
/>
|
|
102
|
+
</Field>
|
|
103
|
+
<Field id="plan-model" label="Pricing">
|
|
104
|
+
<NativeSelect
|
|
105
|
+
id="plan-model"
|
|
106
|
+
value={form.pricing_model}
|
|
107
|
+
onChange={(e) =>
|
|
108
|
+
setForm((f) => withPricingModel(f, e.target.value as PricingModel))
|
|
109
|
+
}
|
|
110
|
+
>
|
|
111
|
+
<NativeSelectOption value="free">Free</NativeSelectOption>
|
|
112
|
+
<NativeSelectOption value="flat">Flat price</NativeSelectOption>
|
|
113
|
+
<NativeSelectOption value="per_seat">Per seat</NativeSelectOption>
|
|
114
|
+
</NativeSelect>
|
|
115
|
+
</Field>
|
|
116
|
+
<Field id="plan-currency" label="Currency">
|
|
117
|
+
<Input id="plan-currency" value={form.currency} onChange={text('currency')} />
|
|
118
|
+
</Field>
|
|
119
|
+
{paid && (
|
|
120
|
+
<>
|
|
121
|
+
<Field id="plan-price-m" label="Stripe price ID (monthly)">
|
|
122
|
+
<Input
|
|
123
|
+
id="plan-price-m"
|
|
124
|
+
placeholder="price_…"
|
|
125
|
+
value={form.stripe_price_month}
|
|
126
|
+
onChange={text('stripe_price_month')}
|
|
127
|
+
/>
|
|
128
|
+
</Field>
|
|
129
|
+
<Field id="plan-amount-m" label="Monthly amount (display)">
|
|
130
|
+
<Input
|
|
131
|
+
id="plan-amount-m"
|
|
132
|
+
value={form.amount_month}
|
|
133
|
+
onChange={text('amount_month')}
|
|
134
|
+
/>
|
|
135
|
+
</Field>
|
|
136
|
+
<Field id="plan-price-y" label="Stripe price ID (yearly)">
|
|
137
|
+
<Input
|
|
138
|
+
id="plan-price-y"
|
|
139
|
+
placeholder="price_…"
|
|
140
|
+
value={form.stripe_price_year}
|
|
141
|
+
onChange={text('stripe_price_year')}
|
|
142
|
+
/>
|
|
143
|
+
</Field>
|
|
144
|
+
<Field id="plan-amount-y" label="Yearly amount (display)">
|
|
145
|
+
<Input
|
|
146
|
+
id="plan-amount-y"
|
|
147
|
+
value={form.amount_year}
|
|
148
|
+
onChange={text('amount_year')}
|
|
149
|
+
/>
|
|
150
|
+
</Field>
|
|
151
|
+
<Field id="plan-trial" label="Trial days">
|
|
152
|
+
<Input id="plan-trial" value={form.trial_days} onChange={text('trial_days')} />
|
|
153
|
+
</Field>
|
|
154
|
+
</>
|
|
155
|
+
)}
|
|
156
|
+
<Field id="plan-order" label="Sort order">
|
|
157
|
+
<Input id="plan-order" value={form.sort_order} onChange={text('sort_order')} />
|
|
158
|
+
</Field>
|
|
159
|
+
</div>
|
|
160
|
+
<Field id="plan-description" label="Description">
|
|
161
|
+
<Textarea
|
|
162
|
+
id="plan-description"
|
|
163
|
+
value={form.description}
|
|
164
|
+
maxLength={MAX_DESCRIPTION}
|
|
165
|
+
onChange={(e) => set('description', e.target.value)}
|
|
166
|
+
/>
|
|
167
|
+
</Field>
|
|
168
|
+
<div className="space-y-1.5">
|
|
169
|
+
<Label>Limits</Label>
|
|
170
|
+
<LimitRows
|
|
171
|
+
rows={form.limits}
|
|
172
|
+
knownKeys={knownKeys}
|
|
173
|
+
onChange={(r) => set('limits', r)}
|
|
174
|
+
/>
|
|
175
|
+
</div>
|
|
176
|
+
<Field id="plan-features" label="Features (comma separated)">
|
|
177
|
+
<Input id="plan-features" value={form.features} onChange={text('features')} />
|
|
178
|
+
</Field>
|
|
179
|
+
<div className="flex flex-wrap gap-6">
|
|
180
|
+
<Label className="flex items-center gap-2">
|
|
181
|
+
<Checkbox
|
|
182
|
+
checked={form.is_public}
|
|
183
|
+
onCheckedChange={(v) => set('is_public', v === true)}
|
|
184
|
+
/>
|
|
185
|
+
Shown to tenants
|
|
186
|
+
</Label>
|
|
187
|
+
<Label className="flex items-center gap-2">
|
|
188
|
+
<Checkbox
|
|
189
|
+
checked={form.is_default}
|
|
190
|
+
disabled={paid}
|
|
191
|
+
onCheckedChange={(v) => set('is_default', v === true)}
|
|
192
|
+
/>
|
|
193
|
+
Default plan for new organisations
|
|
194
|
+
</Label>
|
|
195
|
+
</div>
|
|
196
|
+
{(error ?? serverError) && (
|
|
197
|
+
<p role="alert" className="text-sm text-destructive">
|
|
198
|
+
{error ?? serverError}
|
|
199
|
+
</p>
|
|
200
|
+
)}
|
|
201
|
+
<DialogFooter>
|
|
202
|
+
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
|
203
|
+
Cancel
|
|
204
|
+
</Button>
|
|
205
|
+
<Button type="submit" disabled={busy}>
|
|
206
|
+
{plan ? 'Save plan' : 'Create plan'}
|
|
207
|
+
</Button>
|
|
208
|
+
</DialogFooter>
|
|
209
|
+
</form>
|
|
210
|
+
</DialogContent>
|
|
211
|
+
</Dialog>
|
|
212
|
+
);
|
|
213
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
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
|
+
Table,
|
|
5
|
+
TableBody,
|
|
6
|
+
TableCell,
|
|
7
|
+
TableHead,
|
|
8
|
+
TableHeader,
|
|
9
|
+
TableRow,
|
|
10
|
+
} from '@simple-module-py/ui/components/ui/table';
|
|
11
|
+
import { priceLabel } from '../utils/format';
|
|
12
|
+
import type { Plan } from '../utils/types';
|
|
13
|
+
|
|
14
|
+
interface Props {
|
|
15
|
+
plans: Plan[];
|
|
16
|
+
canManage: boolean;
|
|
17
|
+
onEdit: (plan: Plan) => void;
|
|
18
|
+
onArchive: (plan: Plan) => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const MODEL: Record<Plan['pricing_model'], string> = {
|
|
22
|
+
free: 'Free',
|
|
23
|
+
flat: 'Flat',
|
|
24
|
+
per_seat: 'Per seat',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export function PlansTable({ plans, canManage, onEdit, onArchive }: Props) {
|
|
28
|
+
return (
|
|
29
|
+
<Table>
|
|
30
|
+
<TableHeader>
|
|
31
|
+
<TableRow>
|
|
32
|
+
<TableHead>Plan</TableHead>
|
|
33
|
+
<TableHead>Pricing</TableHead>
|
|
34
|
+
<TableHead>Price</TableHead>
|
|
35
|
+
<TableHead>Seats</TableHead>
|
|
36
|
+
<TableHead className="text-right">Actions</TableHead>
|
|
37
|
+
</TableRow>
|
|
38
|
+
</TableHeader>
|
|
39
|
+
<TableBody>
|
|
40
|
+
{plans.map((plan) => (
|
|
41
|
+
<TableRow key={plan.id} className={plan.archived_at ? 'opacity-60' : undefined}>
|
|
42
|
+
<TableCell>
|
|
43
|
+
<div className="flex flex-wrap items-center gap-2 font-medium">
|
|
44
|
+
{plan.name}
|
|
45
|
+
{plan.is_default && <Badge>Default</Badge>}
|
|
46
|
+
{!plan.is_public && <Badge variant="outline">Hidden</Badge>}
|
|
47
|
+
{plan.archived_at && <Badge variant="secondary">Archived</Badge>}
|
|
48
|
+
</div>
|
|
49
|
+
<div className="font-mono text-xs text-muted-foreground">{plan.key}</div>
|
|
50
|
+
</TableCell>
|
|
51
|
+
<TableCell>{MODEL[plan.pricing_model]}</TableCell>
|
|
52
|
+
<TableCell className="text-sm">
|
|
53
|
+
{priceLabel(plan, 'month')}
|
|
54
|
+
{plan.stripe_price_year && (
|
|
55
|
+
<div className="text-muted-foreground">{priceLabel(plan, 'year')}</div>
|
|
56
|
+
)}
|
|
57
|
+
</TableCell>
|
|
58
|
+
<TableCell>{plan.limits['tenants.seats'] ?? 'Unlimited'}</TableCell>
|
|
59
|
+
<TableCell className="text-right">
|
|
60
|
+
{canManage && !plan.archived_at && (
|
|
61
|
+
<div className="flex justify-end gap-2">
|
|
62
|
+
<Button size="sm" variant="outline" onClick={() => onEdit(plan)}>
|
|
63
|
+
Edit
|
|
64
|
+
</Button>
|
|
65
|
+
{!plan.is_default && (
|
|
66
|
+
<Button size="sm" variant="ghost" onClick={() => onArchive(plan)}>
|
|
67
|
+
Archive
|
|
68
|
+
</Button>
|
|
69
|
+
)}
|
|
70
|
+
</div>
|
|
71
|
+
)}
|
|
72
|
+
</TableCell>
|
|
73
|
+
</TableRow>
|
|
74
|
+
))}
|
|
75
|
+
</TableBody>
|
|
76
|
+
</Table>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { Badge } from '@simple-module-py/ui/components/ui/badge';
|
|
2
|
+
import { statusLabel, statusTone } from '../utils/format';
|
|
3
|
+
import type { SubscriptionStatus } from '../utils/types';
|
|
4
|
+
|
|
5
|
+
export function StatusBadge({ status }: { status: SubscriptionStatus | null }) {
|
|
6
|
+
return <Badge variant={statusTone(status)}>{statusLabel(status)}</Badge>;
|
|
7
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
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
|
+
Table,
|
|
5
|
+
TableBody,
|
|
6
|
+
TableCell,
|
|
7
|
+
TableHead,
|
|
8
|
+
TableHeader,
|
|
9
|
+
TableRow,
|
|
10
|
+
} from '@simple-module-py/ui/components/ui/table';
|
|
11
|
+
import { formatDate } from '../utils/format';
|
|
12
|
+
import type { SubscriptionRow } from '../utils/types';
|
|
13
|
+
import { StatusBadge } from './StatusBadge';
|
|
14
|
+
|
|
15
|
+
interface Props {
|
|
16
|
+
rows: SubscriptionRow[];
|
|
17
|
+
canManage: boolean;
|
|
18
|
+
/** Stripe: resync from the provider. Manual: assign a plan by hand. */
|
|
19
|
+
mode: 'resync' | 'assign';
|
|
20
|
+
busy: boolean;
|
|
21
|
+
onResync: (row: SubscriptionRow) => void;
|
|
22
|
+
onAssign: (row: SubscriptionRow) => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function SubscriptionsTable({ rows, canManage, mode, busy, onResync, onAssign }: Props) {
|
|
26
|
+
return (
|
|
27
|
+
<Table>
|
|
28
|
+
<TableHeader>
|
|
29
|
+
<TableRow>
|
|
30
|
+
<TableHead>Organisation</TableHead>
|
|
31
|
+
<TableHead>Plan</TableHead>
|
|
32
|
+
<TableHead>Status</TableHead>
|
|
33
|
+
<TableHead>Members</TableHead>
|
|
34
|
+
<TableHead>Period end</TableHead>
|
|
35
|
+
<TableHead className="text-right">Actions</TableHead>
|
|
36
|
+
</TableRow>
|
|
37
|
+
</TableHeader>
|
|
38
|
+
<TableBody>
|
|
39
|
+
{rows.map((row) => (
|
|
40
|
+
<TableRow key={row.tenant_id}>
|
|
41
|
+
<TableCell>
|
|
42
|
+
<div className="flex flex-wrap items-center gap-2 font-medium">
|
|
43
|
+
{row.tenant_name}
|
|
44
|
+
{row.tenant_status === 'suspended' && (
|
|
45
|
+
<Badge variant="destructive">
|
|
46
|
+
{row.suspended_by_billing ? 'Suspended (unpaid)' : 'Suspended'}
|
|
47
|
+
</Badge>
|
|
48
|
+
)}
|
|
49
|
+
</div>
|
|
50
|
+
<div className="font-mono text-xs text-muted-foreground">{row.tenant_slug}</div>
|
|
51
|
+
</TableCell>
|
|
52
|
+
<TableCell>
|
|
53
|
+
{row.plan_name}
|
|
54
|
+
{row.interval && <span className="text-muted-foreground"> · {row.interval}ly</span>}
|
|
55
|
+
</TableCell>
|
|
56
|
+
<TableCell>
|
|
57
|
+
<StatusBadge status={row.status} />
|
|
58
|
+
{row.cancel_at_period_end && (
|
|
59
|
+
<div className="mt-1 text-xs text-muted-foreground">Ends at period end</div>
|
|
60
|
+
)}
|
|
61
|
+
</TableCell>
|
|
62
|
+
<TableCell>
|
|
63
|
+
{row.members}
|
|
64
|
+
{row.quantity !== null && row.quantity !== row.members && (
|
|
65
|
+
<span className="text-xs text-muted-foreground"> (billed {row.quantity})</span>
|
|
66
|
+
)}
|
|
67
|
+
</TableCell>
|
|
68
|
+
<TableCell>{formatDate(row.current_period_end) || '—'}</TableCell>
|
|
69
|
+
<TableCell className="text-right">
|
|
70
|
+
{canManage && mode === 'assign' && (
|
|
71
|
+
<Button size="sm" variant="outline" disabled={busy} onClick={() => onAssign(row)}>
|
|
72
|
+
Assign plan
|
|
73
|
+
</Button>
|
|
74
|
+
)}
|
|
75
|
+
{canManage && mode === 'resync' && row.provider_subscription_id && (
|
|
76
|
+
<Button size="sm" variant="outline" disabled={busy} onClick={() => onResync(row)}>
|
|
77
|
+
Resync
|
|
78
|
+
</Button>
|
|
79
|
+
)}
|
|
80
|
+
</TableCell>
|
|
81
|
+
</TableRow>
|
|
82
|
+
))}
|
|
83
|
+
</TableBody>
|
|
84
|
+
</Table>
|
|
85
|
+
);
|
|
86
|
+
}
|
sm_billing/constants.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Billing module constants.
|
|
2
|
+
|
|
3
|
+
Kept out of the call sites so no module name, route, permission or page
|
|
4
|
+
appears as a bare literal — see scripts/check_hardcoded_strings.py.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from enum import StrEnum
|
|
10
|
+
from typing import Final
|
|
11
|
+
|
|
12
|
+
PACKAGE: Final = "sm_billing"
|
|
13
|
+
MODULE_NAME: Final = "Billing"
|
|
14
|
+
|
|
15
|
+
ROUTE_PREFIX_API: Final = "/api/billing"
|
|
16
|
+
VIEW_PREFIX: Final = "/billing"
|
|
17
|
+
ADMIN_VIEW_PREFIX: Final = "/admin/billing"
|
|
18
|
+
WEBHOOK_PATH: Final = "/billing/webhooks/stripe"
|
|
19
|
+
|
|
20
|
+
# Modules this one depends on (value = that module's ModuleMeta.name).
|
|
21
|
+
_MODULE_TENANTS: Final = "Tenants"
|
|
22
|
+
_MODULE_SETTINGS: Final = "Settings"
|
|
23
|
+
|
|
24
|
+
# ── Permissions ──────────────────────────────────────────────────────
|
|
25
|
+
PERM_VIEW: Final = "billing.view"
|
|
26
|
+
PERM_MANAGE: Final = "billing.manage"
|
|
27
|
+
PERM_PLATFORM_VIEW: Final = "billing.platform.view"
|
|
28
|
+
PERM_PLATFORM_MANAGE: Final = "billing.platform.manage"
|
|
29
|
+
|
|
30
|
+
# Tenant roles as the tenants resolver appends them to the principal
|
|
31
|
+
# (``tenants.constants.TENANT_ROLE_PREFIX`` + role) for the active tenant only.
|
|
32
|
+
ROLE_TENANT_OWNER: Final = "tenant:owner"
|
|
33
|
+
ROLE_TENANT_ADMIN: Final = "tenant:admin"
|
|
34
|
+
TENANT_ROLE_OWNER: Final = "owner"
|
|
35
|
+
|
|
36
|
+
# ── Inertia pages (pinned to files under pages/ by a test) ───────────
|
|
37
|
+
_PAGE_BILLING: Final = f"{MODULE_NAME}/Billing"
|
|
38
|
+
_PAGE_PLANS: Final = f"{MODULE_NAME}/Plans"
|
|
39
|
+
_PAGE_SUBSCRIPTIONS: Final = f"{MODULE_NAME}/Subscriptions"
|
|
40
|
+
_PAGE_CONNECTION: Final = f"{MODULE_NAME}/Connection"
|
|
41
|
+
|
|
42
|
+
# ── Menu ─────────────────────────────────────────────────────────────
|
|
43
|
+
MENU_LABEL: Final = "Billing"
|
|
44
|
+
MENU_URL: Final = f"{VIEW_PREFIX}/"
|
|
45
|
+
MENU_ICON: Final = "credit-card"
|
|
46
|
+
MENU_ORDER: Final = 95 # just after Organisations (90)
|
|
47
|
+
ADMIN_MENU_LABEL: Final = "Billing"
|
|
48
|
+
ADMIN_MENU_URL: Final = f"{ADMIN_VIEW_PREFIX}/subscriptions"
|
|
49
|
+
ADMIN_MENU_GROUP: Final = "Access"
|
|
50
|
+
ADMIN_MENU_GROUP_KEY: Final = "ui.nav_groups.access"
|
|
51
|
+
|
|
52
|
+
# ── Providers (stored values) ────────────────────────────────────────
|
|
53
|
+
PROVIDER_MANUAL: Final = "manual"
|
|
54
|
+
PROVIDER_STRIPE: Final = "stripe"
|
|
55
|
+
PROVIDERS: Final = (PROVIDER_MANUAL, PROVIDER_STRIPE)
|
|
56
|
+
|
|
57
|
+
# ── Entitlement keys this module knows about (suggestions in the editor)
|
|
58
|
+
ENTITLEMENT_SEATS: Final = "tenants.seats"
|
|
59
|
+
KNOWN_LIMIT_KEYS: Final = (ENTITLEMENT_SEATS,)
|
|
60
|
+
|
|
61
|
+
DEFAULT_PLAN_KEY: Final = "free"
|
|
62
|
+
DEFAULT_PLAN_NAME: Final = "Free"
|
|
63
|
+
|
|
64
|
+
SECRET_FIELDS: Final = frozenset({"stripe_secret_key", "stripe_webhook_secret"})
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class PricingModel(StrEnum):
|
|
68
|
+
FREE = "free"
|
|
69
|
+
FLAT = "flat"
|
|
70
|
+
PER_SEAT = "per_seat"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class Interval(StrEnum):
|
|
74
|
+
MONTH = "month"
|
|
75
|
+
YEAR = "year"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class SubscriptionStatus(StrEnum):
|
|
79
|
+
TRIALING = "trialing"
|
|
80
|
+
ACTIVE = "active"
|
|
81
|
+
PAST_DUE = "past_due"
|
|
82
|
+
UNPAID = "unpaid"
|
|
83
|
+
CANCELED = "canceled"
|
|
84
|
+
INCOMPLETE = "incomplete"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
#: Statuses whose plan is in force; everything else resolves to the default plan.
|
|
88
|
+
ENTITLED_STATUSES: Final = frozenset(
|
|
89
|
+
{SubscriptionStatus.TRIALING, SubscriptionStatus.ACTIVE, SubscriptionStatus.PAST_DUE}
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
#: Stripe statuses we fold onto ours (``incomplete_expired`` never paid).
|
|
93
|
+
STRIPE_STATUS_MAP: Final = {
|
|
94
|
+
"trialing": SubscriptionStatus.TRIALING,
|
|
95
|
+
"active": SubscriptionStatus.ACTIVE,
|
|
96
|
+
"past_due": SubscriptionStatus.PAST_DUE,
|
|
97
|
+
"unpaid": SubscriptionStatus.UNPAID,
|
|
98
|
+
"canceled": SubscriptionStatus.CANCELED,
|
|
99
|
+
"incomplete": SubscriptionStatus.INCOMPLETE,
|
|
100
|
+
"incomplete_expired": SubscriptionStatus.CANCELED,
|
|
101
|
+
"paused": SubscriptionStatus.UNPAID,
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
#: Webhook event types that trigger a subscription re-fetch.
|
|
105
|
+
HANDLED_EVENTS: Final = frozenset(
|
|
106
|
+
{
|
|
107
|
+
"checkout.session.completed",
|
|
108
|
+
"customer.subscription.created",
|
|
109
|
+
"customer.subscription.updated",
|
|
110
|
+
"customer.subscription.deleted",
|
|
111
|
+
}
|
|
112
|
+
)
|
sm_billing/context.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Which tenant a billing request is about — including one billing suspended.
|
|
2
|
+
|
|
3
|
+
The tenants resolver never makes a suspended organisation the active tenant,
|
|
4
|
+
which is right everywhere except here: the owner of an organisation billing
|
|
5
|
+
suspended for non-payment has to reach the Customer Portal to pay, or the
|
|
6
|
+
suspension can never be lifted from inside the app. So when the session's
|
|
7
|
+
chosen organisation is suspended *by billing* and the user owns it, billing
|
|
8
|
+
opens it in **restore** mode — status and portal only, never checkout or plan
|
|
9
|
+
changes. An organisation an admin suspended stays closed.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
|
|
16
|
+
from fastapi import Request
|
|
17
|
+
from sqlalchemy import select
|
|
18
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
19
|
+
from tenants.constants import SESSION_ACTIVE_TENANT, TenantStatus
|
|
20
|
+
from tenants.models import Membership, Tenant
|
|
21
|
+
|
|
22
|
+
from sm_billing import constants as c
|
|
23
|
+
from sm_billing.resolve import subscription_for
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class BillingContext:
|
|
28
|
+
tenant_id: str
|
|
29
|
+
role: str
|
|
30
|
+
restore: bool = False
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
async def _restorable(request: Request, db: AsyncSession) -> BillingContext | None:
|
|
34
|
+
if not getattr(request.state, "tenant_suspended", False):
|
|
35
|
+
return None
|
|
36
|
+
user = getattr(request.state, "user", None)
|
|
37
|
+
session = request.scope.get("session")
|
|
38
|
+
chosen = session.get(SESSION_ACTIVE_TENANT) if session is not None else None
|
|
39
|
+
if user is None or not chosen:
|
|
40
|
+
return None
|
|
41
|
+
stmt = select(Membership.role).where(
|
|
42
|
+
Membership.tenant_id == chosen, Membership.user_id == str(user.id)
|
|
43
|
+
)
|
|
44
|
+
if (await db.execute(stmt)).scalar_one_or_none() != c.TENANT_ROLE_OWNER:
|
|
45
|
+
return None
|
|
46
|
+
tenant = await db.get(Tenant, chosen)
|
|
47
|
+
if tenant is None or tenant.status != TenantStatus.SUSPENDED:
|
|
48
|
+
return None
|
|
49
|
+
sub = await subscription_for(db, chosen)
|
|
50
|
+
if sub is None or not sub.suspended_by_billing:
|
|
51
|
+
return None
|
|
52
|
+
return BillingContext(tenant_id=chosen, role=c.TENANT_ROLE_OWNER, restore=True)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
async def billing_context(request: Request, db: AsyncSession) -> BillingContext | None:
|
|
56
|
+
"""The organisation this request bills, or ``None`` (pick one first).
|
|
57
|
+
|
|
58
|
+
The chosen-but-suspended organisation wins over the resolver's fallback:
|
|
59
|
+
it is the one the user asked for, and the one that needs paying.
|
|
60
|
+
"""
|
|
61
|
+
restore = await _restorable(request, db)
|
|
62
|
+
if restore is not None:
|
|
63
|
+
return restore
|
|
64
|
+
tenant_id = getattr(request.state, "tenant_id", None)
|
|
65
|
+
role = getattr(request.state, "tenant_role", None)
|
|
66
|
+
if tenant_id is None or role is None:
|
|
67
|
+
return None
|
|
68
|
+
return BillingContext(tenant_id=tenant_id, role=role)
|
|
File without changes
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""The payment-provider seam.
|
|
2
|
+
|
|
3
|
+
Billing speaks to Stripe (or nothing, for ``manual``) only through this
|
|
4
|
+
protocol, in plain dataclasses — no provider SDK object crosses it, so the
|
|
5
|
+
sync, webhook and checkout code is testable with a scripted fake and a second
|
|
6
|
+
provider would be a new class, not a refactor.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Mapping
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
from typing import Protocol, runtime_checkable
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ProviderError(Exception):
|
|
18
|
+
"""The provider refused or failed a call (network, auth, unknown object)."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class InvalidWebhookSignature(ProviderError): # noqa: N818 - reads as the condition
|
|
22
|
+
"""The webhook body does not carry a valid signature → HTTP 400."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class WebhookEvent:
|
|
27
|
+
id: str
|
|
28
|
+
type: str
|
|
29
|
+
subscription_id: str | None = None
|
|
30
|
+
tenant_id: str | None = None
|
|
31
|
+
customer_id: str | None = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class SubscriptionSnapshot:
|
|
36
|
+
"""The provider's current view of one subscription, already normalised.
|
|
37
|
+
|
|
38
|
+
``status`` is the provider's raw status string; ``sync`` maps it.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
id: str
|
|
42
|
+
customer_id: str | None
|
|
43
|
+
tenant_id: str | None
|
|
44
|
+
status: str
|
|
45
|
+
price_id: str | None
|
|
46
|
+
quantity: int
|
|
47
|
+
trial_end: datetime | None
|
|
48
|
+
current_period_end: datetime | None
|
|
49
|
+
cancel_at_period_end: bool
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class CheckoutSession:
|
|
54
|
+
id: str
|
|
55
|
+
url: str
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class PriceInfo:
|
|
60
|
+
id: str
|
|
61
|
+
currency: str
|
|
62
|
+
interval: str | None # "month" / "year"; None for one-off prices
|
|
63
|
+
active: bool
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@runtime_checkable
|
|
67
|
+
class BillingProvider(Protocol):
|
|
68
|
+
name: str
|
|
69
|
+
supports_checkout: bool
|
|
70
|
+
|
|
71
|
+
async def ensure_customer(self, tenant_id: str, name: str, email: str | None) -> str | None:
|
|
72
|
+
"""Create the customer; returns its id (``None`` for providers without one)."""
|
|
73
|
+
...
|
|
74
|
+
|
|
75
|
+
async def create_checkout(
|
|
76
|
+
self,
|
|
77
|
+
*,
|
|
78
|
+
customer_id: str | None,
|
|
79
|
+
price_id: str,
|
|
80
|
+
quantity: int,
|
|
81
|
+
trial_days: int,
|
|
82
|
+
tenant_id: str,
|
|
83
|
+
success_url: str,
|
|
84
|
+
cancel_url: str,
|
|
85
|
+
) -> CheckoutSession: ...
|
|
86
|
+
|
|
87
|
+
async def expire_checkout(self, session_id: str) -> None:
|
|
88
|
+
"""Close an open Checkout session so it can no longer be paid."""
|
|
89
|
+
...
|
|
90
|
+
|
|
91
|
+
async def live_subscription_ids(self, customer_id: str) -> list[str]:
|
|
92
|
+
"""The customer's subscriptions that still bill or grant access."""
|
|
93
|
+
...
|
|
94
|
+
|
|
95
|
+
async def portal_url(self, customer_id: str, return_url: str) -> str: ...
|
|
96
|
+
|
|
97
|
+
async def change_plan(self, subscription_id: str, price_id: str, quantity: int) -> None: ...
|
|
98
|
+
|
|
99
|
+
async def set_quantity(self, subscription_id: str, quantity: int) -> None: ...
|
|
100
|
+
|
|
101
|
+
async def cancel_at_period_end(self, subscription_id: str) -> None: ...
|
|
102
|
+
|
|
103
|
+
def parse_webhook(self, body: bytes, headers: Mapping[str, str]) -> WebhookEvent: ...
|
|
104
|
+
|
|
105
|
+
async def fetch_subscription(self, subscription_id: str) -> SubscriptionSnapshot: ...
|
|
106
|
+
|
|
107
|
+
async def verify_price(self, price_id: str) -> PriceInfo: ...
|