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,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
+ }
@@ -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: ...