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
sm_billing/seats.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Per-seat quantity sync on membership changes.
|
|
2
|
+
|
|
3
|
+
The tenants module publishes ``MembershipAdded``/``MembershipRemoved`` after
|
|
4
|
+
its commit, inline in the request. The handler opens its own session, and a
|
|
5
|
+
provider failure is logged, never raised: the membership change already
|
|
6
|
+
happened and must stand. ``reconcile`` pushes any drift left behind.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
from sm_billing import constants as c
|
|
15
|
+
from sm_billing.lifecycle import tenant_service
|
|
16
|
+
from sm_billing.resolve import effective_plan
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from fastapi import FastAPI
|
|
20
|
+
from simple_module_core.events import EventBus
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
async def push_quantity(app: FastAPI, tenant_id: str) -> bool:
|
|
26
|
+
"""Make the provider's quantity equal the member count. ``True`` if pushed."""
|
|
27
|
+
provider = getattr(app.state, c.PACKAGE).provider
|
|
28
|
+
if provider is None or not provider.supports_checkout:
|
|
29
|
+
return False
|
|
30
|
+
async with app.state.sm.db.session_factory() as session:
|
|
31
|
+
plan, sub = await effective_plan(session, tenant_id)
|
|
32
|
+
if (
|
|
33
|
+
sub is None
|
|
34
|
+
or sub.provider_subscription_id is None
|
|
35
|
+
or plan.pricing_model != c.PricingModel.PER_SEAT
|
|
36
|
+
):
|
|
37
|
+
return False
|
|
38
|
+
counts = await tenant_service(session, app).member_counts([tenant_id])
|
|
39
|
+
members = max(1, counts.get(tenant_id, 0))
|
|
40
|
+
if members == sub.quantity:
|
|
41
|
+
return False
|
|
42
|
+
await provider.set_quantity(sub.provider_subscription_id, members)
|
|
43
|
+
sub.quantity = members
|
|
44
|
+
await session.commit()
|
|
45
|
+
return True
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def subscribe(bus: EventBus, app: FastAPI) -> None:
|
|
49
|
+
"""Subscribe the membership handlers."""
|
|
50
|
+
from tenants.contracts.events import MembershipAdded, MembershipRemoved
|
|
51
|
+
|
|
52
|
+
async def _sync(event: MembershipAdded | MembershipRemoved) -> None:
|
|
53
|
+
try:
|
|
54
|
+
await push_quantity(app, event.tenant_id)
|
|
55
|
+
except Exception:
|
|
56
|
+
logger.warning(
|
|
57
|
+
"billing: seat sync failed for tenant %s; reconcile will retry",
|
|
58
|
+
event.tenant_id,
|
|
59
|
+
exc_info=True,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
bus.subscribe(MembershipAdded, _sync)
|
|
63
|
+
bus.subscribe(MembershipRemoved, _sync)
|
sm_billing/services.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Module-scoped state on ``app.state.sm_billing``.
|
|
2
|
+
|
|
3
|
+
Not frozen: the host's hydrate step assigns the DB-resolved settings onto
|
|
4
|
+
this object at lifespan start, and the Settings screen assigns again on every
|
|
5
|
+
save. ``provider`` is built in ``on_startup`` from the hydrated settings.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
from sm_billing import constants as c
|
|
14
|
+
from sm_billing.errors import BillingError
|
|
15
|
+
from sm_billing.settings import BillingSettings
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from fastapi import FastAPI
|
|
19
|
+
|
|
20
|
+
from sm_billing.contracts.provider import BillingProvider
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class BillingServices:
|
|
25
|
+
settings: BillingSettings
|
|
26
|
+
provider: BillingProvider | None = None
|
|
27
|
+
#: Why the configured provider could not be used (shown on the admin screens).
|
|
28
|
+
provider_error: str = ""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def current_provider(app: FastAPI) -> BillingProvider:
|
|
32
|
+
"""The running provider, for code that cannot proceed without one."""
|
|
33
|
+
provider = getattr(app.state, c.PACKAGE).provider
|
|
34
|
+
if provider is None:
|
|
35
|
+
raise BillingError("provider_unavailable", 503)
|
|
36
|
+
return provider
|
sm_billing/settings.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Billing settings — DB-backed via ``register_module_settings``.
|
|
2
|
+
|
|
3
|
+
No environment, ``.env`` or secrets-file sources (``DbBackedSettings``): an
|
|
4
|
+
``SM_BILLING_*`` variable is dead. Configure on the Settings screen, on
|
|
5
|
+
``/admin/billing/connection`` (which encrypts the Stripe secrets), or with
|
|
6
|
+
``scripts/set_setting.py``.
|
|
7
|
+
|
|
8
|
+
``provider`` is read once, in ``on_startup`` — the provider object is built
|
|
9
|
+
then — so changing it needs a restart.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from pydantic import Field
|
|
15
|
+
from simple_module_core.settings_base import DbBackedSettings
|
|
16
|
+
|
|
17
|
+
from sm_billing import constants as c
|
|
18
|
+
|
|
19
|
+
_RESTART = {"requires_restart": True, "group": "Billing", "choices": list(c.PROVIDERS)}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class BillingSettings(DbBackedSettings):
|
|
23
|
+
"""Configuration for the billing module."""
|
|
24
|
+
|
|
25
|
+
provider: str = Field(
|
|
26
|
+
default=c.PROVIDER_MANUAL,
|
|
27
|
+
description=(
|
|
28
|
+
"Payment provider. 'manual': admins assign plans by hand, no payment. "
|
|
29
|
+
"'stripe': Checkout, Customer Portal and webhooks (needs both Stripe secrets)."
|
|
30
|
+
),
|
|
31
|
+
json_schema_extra=_RESTART,
|
|
32
|
+
)
|
|
33
|
+
stripe_secret_key: str = Field(
|
|
34
|
+
default="",
|
|
35
|
+
description="Stripe secret API key (sk_live_… / sk_test_…). Stored encrypted.",
|
|
36
|
+
)
|
|
37
|
+
stripe_webhook_secret: str = Field(
|
|
38
|
+
default="",
|
|
39
|
+
description="Signing secret of the Stripe webhook endpoint (whsec_…). Stored encrypted.",
|
|
40
|
+
)
|
|
41
|
+
return_base_url: str = Field(
|
|
42
|
+
default="",
|
|
43
|
+
description=(
|
|
44
|
+
"Origin Stripe returns visitors to after Checkout and the portal, e.g. "
|
|
45
|
+
"https://app.example.com. Empty: the request's own origin."
|
|
46
|
+
),
|
|
47
|
+
)
|
sm_billing/startup.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Lifespan-start work: seed the default plan, build the provider, install entitlements.
|
|
2
|
+
|
|
3
|
+
Runs after settings hydration and after ``tenants``' own startup
|
|
4
|
+
(``depends_on``), so ``app.state.sm_billing.settings`` holds the DB values and
|
|
5
|
+
``app.state.tenants`` exists.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
from sm_billing import constants as c
|
|
13
|
+
from sm_billing.entitlements import PlanEntitlements
|
|
14
|
+
from sm_billing.plans import seed_default_plan
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from fastapi import FastAPI
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
async def run(app: FastAPI) -> None:
|
|
21
|
+
session_factory = app.state.sm.db.session_factory
|
|
22
|
+
await seed_default_plan(session_factory)
|
|
23
|
+
app.state.tenants.entitlements = PlanEntitlements(session_factory)
|
|
24
|
+
services = getattr(app.state, c.PACKAGE)
|
|
25
|
+
from sm_billing.providers.factory import install_provider
|
|
26
|
+
|
|
27
|
+
install_provider(services)
|
sm_billing/sync.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Write a provider snapshot onto the local mirror.
|
|
2
|
+
|
|
3
|
+
One function, used by webhooks, reconcile and the admin "Resync" button, so
|
|
4
|
+
there is exactly one place that decides how provider state becomes local
|
|
5
|
+
state. Failures raise ``SyncError`` rather than guessing: an unknown price or
|
|
6
|
+
tenant must never quietly hand a paying customer the default plan.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from datetime import UTC, datetime
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
from sqlalchemy import select
|
|
15
|
+
from tenants.models import Tenant
|
|
16
|
+
|
|
17
|
+
from sm_billing.constants import ENTITLED_STATUSES, STRIPE_STATUS_MAP, SubscriptionStatus
|
|
18
|
+
from sm_billing.lifecycle import apply_lifecycle
|
|
19
|
+
from sm_billing.models import Customer, Subscription
|
|
20
|
+
from sm_billing.plans import PlanService
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from fastapi import FastAPI
|
|
24
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
25
|
+
|
|
26
|
+
from sm_billing.contracts.provider import BillingProvider, SubscriptionSnapshot
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class SyncError(Exception):
|
|
30
|
+
"""The snapshot cannot be mapped onto a tenant and plan."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, code: str, detail: str = "") -> None:
|
|
33
|
+
super().__init__(f"{code}: {detail}" if detail else code)
|
|
34
|
+
self.code = code
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
async def _tenant_for(db: AsyncSession, snap: SubscriptionSnapshot) -> str:
|
|
38
|
+
if snap.tenant_id and await db.get(Tenant, snap.tenant_id) is not None:
|
|
39
|
+
return snap.tenant_id
|
|
40
|
+
if snap.customer_id:
|
|
41
|
+
stmt = select(Customer).where(Customer.provider_customer_id == snap.customer_id)
|
|
42
|
+
customer = (await db.execute(stmt)).scalar_one_or_none()
|
|
43
|
+
if customer is not None:
|
|
44
|
+
return customer.tenant_id
|
|
45
|
+
raise SyncError("unknown_tenant", f"subscription {snap.id}")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
async def _link_customer(db: AsyncSession, tenant_id: str, customer_id: str | None, provider: str):
|
|
49
|
+
if not customer_id:
|
|
50
|
+
return
|
|
51
|
+
customer = await db.get(Customer, tenant_id)
|
|
52
|
+
if customer is None:
|
|
53
|
+
db.add(Customer(tenant_id=tenant_id, provider=provider, provider_customer_id=customer_id))
|
|
54
|
+
elif customer.provider_customer_id != customer_id:
|
|
55
|
+
customer.provider_customer_id = customer_id
|
|
56
|
+
customer.provider = provider
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
async def apply_snapshot(
|
|
60
|
+
db: AsyncSession, app: FastAPI, snap: SubscriptionSnapshot, *, provider: str
|
|
61
|
+
) -> Subscription:
|
|
62
|
+
"""Upsert the tenant's subscription from ``snap`` and apply the lifecycle.
|
|
63
|
+
|
|
64
|
+
Flushes, never commits (see ``apply_lifecycle`` for why the caller must
|
|
65
|
+
finalize). Returns the tenant's subscription row.
|
|
66
|
+
"""
|
|
67
|
+
status = STRIPE_STATUS_MAP.get(snap.status)
|
|
68
|
+
if status is None:
|
|
69
|
+
raise SyncError("unknown_status", snap.status)
|
|
70
|
+
tenant_id = await _tenant_for(db, snap)
|
|
71
|
+
stmt = select(Subscription).where(Subscription.tenant_id == tenant_id)
|
|
72
|
+
sub = (await db.execute(stmt)).scalar_one_or_none()
|
|
73
|
+
|
|
74
|
+
# A late event for a tenant's *previous* subscription (canceled before
|
|
75
|
+
# they re-subscribed) must not overwrite the live one.
|
|
76
|
+
if (
|
|
77
|
+
sub is not None
|
|
78
|
+
and sub.provider_subscription_id not in (None, snap.id)
|
|
79
|
+
and status == SubscriptionStatus.CANCELED
|
|
80
|
+
):
|
|
81
|
+
return sub
|
|
82
|
+
|
|
83
|
+
match = await PlanService(db).by_price(snap.price_id) if snap.price_id else None
|
|
84
|
+
if match is not None:
|
|
85
|
+
plan_id, interval = match[0].id, match[1]
|
|
86
|
+
elif sub is not None and status not in ENTITLED_STATUSES:
|
|
87
|
+
# A lapse needs no plan: apply the cancel / "unpaid" on the plan they
|
|
88
|
+
# had, so an unmappable price never keeps paid access alive.
|
|
89
|
+
plan_id, interval = sub.plan_id, sub.interval
|
|
90
|
+
elif sub is None and status not in ENTITLED_STATUSES:
|
|
91
|
+
# A lapse of something never mirrored here (e.g. a checkout that was
|
|
92
|
+
# abandoned on a since-deleted price): there is no access to revoke.
|
|
93
|
+
raise SyncError("nothing_to_apply", f"{snap.status} on {snap.price_id or '(none)'}")
|
|
94
|
+
else:
|
|
95
|
+
raise SyncError("unknown_price", snap.price_id or "(none)")
|
|
96
|
+
|
|
97
|
+
await _link_customer(db, tenant_id, snap.customer_id, provider)
|
|
98
|
+
if sub is None:
|
|
99
|
+
sub = Subscription(tenant_id=tenant_id, plan_id=plan_id)
|
|
100
|
+
db.add(sub)
|
|
101
|
+
sub.plan_id = plan_id
|
|
102
|
+
sub.status = status
|
|
103
|
+
sub.interval = interval
|
|
104
|
+
sub.provider_subscription_id = snap.id
|
|
105
|
+
sub.quantity = max(1, snap.quantity)
|
|
106
|
+
sub.trial_end = snap.trial_end
|
|
107
|
+
sub.current_period_end = snap.current_period_end
|
|
108
|
+
sub.cancel_at_period_end = snap.cancel_at_period_end
|
|
109
|
+
sub.synced_at = datetime.now(UTC)
|
|
110
|
+
await db.flush()
|
|
111
|
+
await apply_lifecycle(db, app, sub)
|
|
112
|
+
return sub
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
async def resync(
|
|
116
|
+
db: AsyncSession, app: FastAPI, provider: BillingProvider, subscription_id: str
|
|
117
|
+
) -> Subscription:
|
|
118
|
+
"""Re-fetch ``subscription_id`` from the provider and apply it."""
|
|
119
|
+
snapshot = await provider.fetch_subscription(subscription_id)
|
|
120
|
+
return await apply_snapshot(db, app, snapshot, provider=provider.name)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { ApiError, api, describeError } from './api';
|
|
3
|
+
|
|
4
|
+
afterEach(() => vi.unstubAllGlobals());
|
|
5
|
+
|
|
6
|
+
describe('describeError', () => {
|
|
7
|
+
it('explains the seat guard with numbers', () => {
|
|
8
|
+
expect(describeError('too_many_members', { used: 5, limit: 2 })).toBe(
|
|
9
|
+
'That plan allows 2 seats and you use 5 — remove 3 first.',
|
|
10
|
+
);
|
|
11
|
+
});
|
|
12
|
+
it('falls back for unknown codes', () => {
|
|
13
|
+
expect(describeError('weird')).toBe('Something went wrong. Please try again.');
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe('api', () => {
|
|
18
|
+
it('sends the CSRF header on writes only', async () => {
|
|
19
|
+
const fetchMock = vi.fn(async () => new Response(JSON.stringify({ url: 'x' })));
|
|
20
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
21
|
+
await api('/a', 'tok', { body: { plan_id: 1 } });
|
|
22
|
+
await api('/b', 'tok');
|
|
23
|
+
const [, write] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
|
|
24
|
+
const [, read] = fetchMock.mock.calls[1] as unknown as [string, RequestInit];
|
|
25
|
+
expect((write.headers as Record<string, string>)['X-CSRF-Token']).toBe('tok');
|
|
26
|
+
expect(write.method).toBe('POST');
|
|
27
|
+
expect((read.headers as Record<string, string>)['X-CSRF-Token']).toBeUndefined();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('throws ApiError with the server detail', async () => {
|
|
31
|
+
vi.stubGlobal(
|
|
32
|
+
'fetch',
|
|
33
|
+
vi.fn(async () => new Response(JSON.stringify({ detail: 'plan_is_free' }), { status: 400 })),
|
|
34
|
+
);
|
|
35
|
+
await expect(api('/a', 't', { body: {} })).rejects.toMatchObject({
|
|
36
|
+
status: 400,
|
|
37
|
+
detail: 'plan_is_free',
|
|
38
|
+
message: 'The free plan needs no checkout.',
|
|
39
|
+
});
|
|
40
|
+
expect(ApiError).toBeDefined();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('reads a FastAPI validation error list', async () => {
|
|
44
|
+
const detail = [
|
|
45
|
+
{ loc: ['body', 'trial_days'], msg: 'Input should be less than or equal to 730' },
|
|
46
|
+
];
|
|
47
|
+
vi.stubGlobal(
|
|
48
|
+
'fetch',
|
|
49
|
+
vi.fn(async () => new Response(JSON.stringify({ detail }), { status: 422 })),
|
|
50
|
+
);
|
|
51
|
+
await expect(api('/a', 't', { body: {} })).rejects.toMatchObject({
|
|
52
|
+
status: 422,
|
|
53
|
+
detail: 'validation_error',
|
|
54
|
+
message: 'trial_days: Input should be less than or equal to 730.',
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
});
|
sm_billing/utils/api.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/** fetch wrapper for the billing API: JSON in/out, CSRF header, readable errors. */
|
|
2
|
+
|
|
3
|
+
const CSRF_HEADER = 'X-CSRF-Token';
|
|
4
|
+
|
|
5
|
+
export class ApiError extends Error {
|
|
6
|
+
constructor(
|
|
7
|
+
public readonly status: number,
|
|
8
|
+
public readonly detail: string,
|
|
9
|
+
public readonly body: Record<string, unknown>,
|
|
10
|
+
) {
|
|
11
|
+
super(describeError(detail, body));
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const MESSAGES: Record<string, string> = {
|
|
16
|
+
checkout_unavailable: 'Online payment is not set up for this site.',
|
|
17
|
+
plan_not_found: 'That plan is no longer available.',
|
|
18
|
+
plan_is_free: 'The free plan needs no checkout.',
|
|
19
|
+
interval_unavailable: 'That plan is not sold for this billing period.',
|
|
20
|
+
already_subscribed: 'You already have a subscription — change plan instead.',
|
|
21
|
+
already_on_plan: 'You are already on this plan.',
|
|
22
|
+
no_subscription: 'There is no paid subscription to change yet.',
|
|
23
|
+
no_customer: 'There is no billing account yet — subscribe to a plan first.',
|
|
24
|
+
tenant_owner_required: 'Only the organisation owner can change billing.',
|
|
25
|
+
tenant_required: 'Pick an organisation first.',
|
|
26
|
+
provider_error: 'The payment provider refused the request.',
|
|
27
|
+
provider_managed: 'Subscriptions are managed by Stripe; use Resync instead.',
|
|
28
|
+
no_provider_subscription: 'This tenant has no Stripe subscription to resync.',
|
|
29
|
+
tenant_not_found: 'That organisation no longer exists.',
|
|
30
|
+
paid_plan_needs_price: 'A paid plan needs at least one Stripe price ID.',
|
|
31
|
+
free_plan_has_price: 'Free plans have no Stripe price.',
|
|
32
|
+
free_plan_has_trial: 'Free plans have no trial.',
|
|
33
|
+
default_must_be_free: 'Only a free plan can be the default.',
|
|
34
|
+
default_required: 'Mark another free plan as default first.',
|
|
35
|
+
cannot_archive_default: 'The default plan cannot be archived.',
|
|
36
|
+
plan_key_taken: 'Another plan already uses that key.',
|
|
37
|
+
plan_key_immutable: 'A plan key cannot change once created.',
|
|
38
|
+
price_taken: 'Another plan already uses that Stripe price.',
|
|
39
|
+
price_not_found: 'Stripe has no price with that ID.',
|
|
40
|
+
invalid_return_url: 'The return URL must be an http(s) origin like https://app.example.com.',
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export function describeError(detail: string, body: Record<string, unknown> = {}): string {
|
|
44
|
+
if (detail === 'too_many_members') {
|
|
45
|
+
const over = Number(body.used) - Number(body.limit);
|
|
46
|
+
return `That plan allows ${body.limit} seats and you use ${body.used} — remove ${over} first.`;
|
|
47
|
+
}
|
|
48
|
+
if (detail === 'validation_error' && typeof body.message === 'string') return body.message;
|
|
49
|
+
if (detail === 'price_mismatch') return `Stripe price ${body.price}: ${body.reason}.`;
|
|
50
|
+
if (detail === 'provider_error' && typeof body.message === 'string') {
|
|
51
|
+
return `${MESSAGES.provider_error} ${body.message}`;
|
|
52
|
+
}
|
|
53
|
+
return MESSAGES[detail] ?? 'Something went wrong. Please try again.';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** FastAPI's 422 body: `detail` is a list of `{loc, msg}`; show the first. */
|
|
57
|
+
function validationMessage(items: unknown[]): string {
|
|
58
|
+
const first = (items[0] ?? {}) as { loc?: unknown; msg?: unknown };
|
|
59
|
+
const msg = typeof first.msg === 'string' ? first.msg : 'Invalid input';
|
|
60
|
+
const field = Array.isArray(first.loc) ? first.loc[first.loc.length - 1] : undefined;
|
|
61
|
+
return field === undefined ? `${msg}.` : `${field}: ${msg}.`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function api<T>(
|
|
65
|
+
url: string,
|
|
66
|
+
csrf: string,
|
|
67
|
+
init: { method?: string; body?: unknown } = {},
|
|
68
|
+
): Promise<T> {
|
|
69
|
+
const method = init.method ?? (init.body === undefined ? 'GET' : 'POST');
|
|
70
|
+
const response = await fetch(url, {
|
|
71
|
+
method,
|
|
72
|
+
credentials: 'same-origin',
|
|
73
|
+
headers: {
|
|
74
|
+
Accept: 'application/json',
|
|
75
|
+
...(init.body === undefined ? {} : { 'Content-Type': 'application/json' }),
|
|
76
|
+
...(method === 'GET' ? {} : { [CSRF_HEADER]: csrf }),
|
|
77
|
+
},
|
|
78
|
+
body: init.body === undefined ? undefined : JSON.stringify(init.body),
|
|
79
|
+
});
|
|
80
|
+
const body = (await response.json().catch(() => ({}))) as Record<string, unknown>;
|
|
81
|
+
if (!response.ok) {
|
|
82
|
+
if (Array.isArray(body.detail)) {
|
|
83
|
+
throw new ApiError(response.status, 'validation_error', {
|
|
84
|
+
...body,
|
|
85
|
+
message: validationMessage(body.detail),
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
const detail = typeof body.detail === 'string' ? body.detail : `http_${response.status}`;
|
|
89
|
+
throw new ApiError(response.status, detail, body);
|
|
90
|
+
}
|
|
91
|
+
return body as T;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export const BILLING_API = '/api/billing';
|
|
95
|
+
export const ADMIN_API = '/api/billing/admin';
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { formatMoney, isActivated, planCta, priceLabel, statusTone } from './format';
|
|
3
|
+
import type { Plan, Status } from './types';
|
|
4
|
+
|
|
5
|
+
const plan = (over: Partial<Plan> = {}): Plan => ({
|
|
6
|
+
id: 2,
|
|
7
|
+
key: 'team',
|
|
8
|
+
name: 'Team',
|
|
9
|
+
description: '',
|
|
10
|
+
pricing_model: 'flat',
|
|
11
|
+
currency: 'eur',
|
|
12
|
+
amount_month: 1900,
|
|
13
|
+
amount_year: 19000,
|
|
14
|
+
stripe_price_month: 'pm',
|
|
15
|
+
stripe_price_year: 'py',
|
|
16
|
+
trial_days: 0,
|
|
17
|
+
limits: {},
|
|
18
|
+
features: [],
|
|
19
|
+
is_default: false,
|
|
20
|
+
is_public: true,
|
|
21
|
+
sort_order: 0,
|
|
22
|
+
archived_at: null,
|
|
23
|
+
...over,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const free = plan({
|
|
27
|
+
id: 1,
|
|
28
|
+
key: 'free',
|
|
29
|
+
pricing_model: 'free',
|
|
30
|
+
is_default: true,
|
|
31
|
+
stripe_price_month: null,
|
|
32
|
+
stripe_price_year: null,
|
|
33
|
+
amount_month: null,
|
|
34
|
+
amount_year: null,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const status = (over: Partial<Status> = {}): Status => ({
|
|
38
|
+
plan: free,
|
|
39
|
+
subscription: null,
|
|
40
|
+
seats: { used: 1, limit: null },
|
|
41
|
+
provider: 'stripe',
|
|
42
|
+
checkout_available: true,
|
|
43
|
+
portal_available: false,
|
|
44
|
+
...over,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const paidSub = {
|
|
48
|
+
status: 'active' as const,
|
|
49
|
+
interval: 'month' as const,
|
|
50
|
+
quantity: 1,
|
|
51
|
+
trial_end: null,
|
|
52
|
+
current_period_end: null,
|
|
53
|
+
cancel_at_period_end: false,
|
|
54
|
+
has_provider_subscription: true,
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
describe('formatMoney', () => {
|
|
58
|
+
it('formats minor units in the plan currency', () => {
|
|
59
|
+
expect(formatMoney(1900, 'eur', 'en-US')).toBe('€19.00');
|
|
60
|
+
expect(formatMoney(0, 'usd', 'en-US')).toBe('$0.00');
|
|
61
|
+
});
|
|
62
|
+
it('returns an empty string for a missing amount', () => {
|
|
63
|
+
expect(formatMoney(null, 'eur')).toBe('');
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('priceLabel', () => {
|
|
68
|
+
it('shows free plans as Free', () => {
|
|
69
|
+
expect(priceLabel(free, 'month')).toBe('Free');
|
|
70
|
+
});
|
|
71
|
+
it('adds per seat and the interval', () => {
|
|
72
|
+
const seat = plan({ pricing_model: 'per_seat' });
|
|
73
|
+
expect(priceLabel(seat, 'month', 'en-US')).toBe('€19.00 / seat / month');
|
|
74
|
+
expect(priceLabel(plan(), 'year', 'en-US')).toBe('€190.00 / year');
|
|
75
|
+
});
|
|
76
|
+
it('says when an interval is not sold', () => {
|
|
77
|
+
expect(priceLabel(plan({ stripe_price_year: null }), 'year')).toBe('Not available yearly');
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe('planCta', () => {
|
|
82
|
+
it('marks the plan in force as current', () => {
|
|
83
|
+
expect(planCta(status(), free, 'month')).toBe('current');
|
|
84
|
+
});
|
|
85
|
+
it('sends a free tenant to checkout', () => {
|
|
86
|
+
expect(planCta(status(), plan(), 'month')).toBe('checkout');
|
|
87
|
+
});
|
|
88
|
+
it('switches between paid plans on a live subscription', () => {
|
|
89
|
+
const s = status({ plan: plan(), subscription: paidSub });
|
|
90
|
+
expect(planCta(s, plan({ id: 3, key: 'pro' }), 'month')).toBe('change');
|
|
91
|
+
expect(planCta(s, plan(), 'year')).toBe('change');
|
|
92
|
+
expect(planCta(s, plan(), 'month')).toBe('current');
|
|
93
|
+
});
|
|
94
|
+
it('downgrades to free by cancelling at period end', () => {
|
|
95
|
+
const s = status({ plan: plan(), subscription: paidSub });
|
|
96
|
+
expect(planCta(s, free, 'month')).toBe('downgrade');
|
|
97
|
+
const ending = status({
|
|
98
|
+
plan: plan(),
|
|
99
|
+
subscription: { ...paidSub, cancel_at_period_end: true },
|
|
100
|
+
});
|
|
101
|
+
expect(planCta(ending, free, 'month')).toBe('current');
|
|
102
|
+
});
|
|
103
|
+
it('is unavailable without checkout or without a price', () => {
|
|
104
|
+
expect(planCta(status({ checkout_available: false }), plan(), 'month')).toBe('unavailable');
|
|
105
|
+
expect(planCta(status(), plan({ stripe_price_year: null }), 'year')).toBe('unavailable');
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe('isActivated', () => {
|
|
110
|
+
it('needs a live provider subscription, not a stale canceled one', () => {
|
|
111
|
+
expect(isActivated(status({ subscription: paidSub }))).toBe(true);
|
|
112
|
+
expect(isActivated(status({ subscription: { ...paidSub, status: 'trialing' } }))).toBe(true);
|
|
113
|
+
expect(isActivated(status({ subscription: { ...paidSub, status: 'canceled' } }))).toBe(false);
|
|
114
|
+
expect(isActivated(status())).toBe(false);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe('statusTone', () => {
|
|
119
|
+
it('flags payment problems', () => {
|
|
120
|
+
expect(statusTone('past_due')).toBe('destructive');
|
|
121
|
+
expect(statusTone('unpaid')).toBe('destructive');
|
|
122
|
+
expect(statusTone('active')).toBe('default');
|
|
123
|
+
expect(statusTone(null)).toBe('outline');
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { Interval, Plan, Status, SubscriptionStatus } from './types';
|
|
2
|
+
|
|
3
|
+
/** Minor units → localized currency string; '' when there is no amount. */
|
|
4
|
+
export function formatMoney(minor: number | null, currency: string, locale?: string): string {
|
|
5
|
+
if (minor === null || minor === undefined) return '';
|
|
6
|
+
return new Intl.NumberFormat(locale, {
|
|
7
|
+
style: 'currency',
|
|
8
|
+
currency: currency.toUpperCase(),
|
|
9
|
+
}).format(minor / 100);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function formatDate(iso: string | null, locale?: string): string {
|
|
13
|
+
if (!iso) return '';
|
|
14
|
+
return new Intl.DateTimeFormat(locale, { dateStyle: 'medium' }).format(new Date(iso));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function priceFor(plan: Plan, interval: Interval): string | null {
|
|
18
|
+
return interval === 'month' ? plan.stripe_price_month : plan.stripe_price_year;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function priceLabel(plan: Plan, interval: Interval, locale?: string): string {
|
|
22
|
+
if (plan.pricing_model === 'free') return 'Free';
|
|
23
|
+
if (!priceFor(plan, interval)) {
|
|
24
|
+
return interval === 'month' ? 'Not available monthly' : 'Not available yearly';
|
|
25
|
+
}
|
|
26
|
+
const amount = interval === 'month' ? plan.amount_month : plan.amount_year;
|
|
27
|
+
const money = formatMoney(amount, plan.currency, locale) || 'Paid';
|
|
28
|
+
const seat = plan.pricing_model === 'per_seat' ? ' / seat' : '';
|
|
29
|
+
return `${money}${seat} / ${interval}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const LIVE: ReadonlyArray<SubscriptionStatus> = ['active', 'trialing', 'past_due'];
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Checkout has landed: a provider subscription that grants access. Not just
|
|
36
|
+
* "has a provider id" — a tenant re-subscribing still carries their old,
|
|
37
|
+
* canceled one, which would end the wait before the new one arrived.
|
|
38
|
+
*/
|
|
39
|
+
export function isActivated(status: Status): boolean {
|
|
40
|
+
const sub = status.subscription;
|
|
41
|
+
return Boolean(sub?.has_provider_subscription && sub.status && LIVE.includes(sub.status));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** What the plan picker's button does for ``target``. */
|
|
45
|
+
export type PlanCta = 'current' | 'checkout' | 'change' | 'downgrade' | 'unavailable';
|
|
46
|
+
|
|
47
|
+
export function planCta(status: Status, target: Plan, interval: Interval): PlanCta {
|
|
48
|
+
const sub = status.subscription;
|
|
49
|
+
const live =
|
|
50
|
+
sub?.has_provider_subscription &&
|
|
51
|
+
(sub.status === 'active' || sub.status === 'trialing' || sub.status === 'past_due');
|
|
52
|
+
if (target.pricing_model === 'free') {
|
|
53
|
+
if (!live) return status.plan.id === target.id ? 'current' : 'unavailable';
|
|
54
|
+
return sub?.cancel_at_period_end ? 'current' : 'downgrade';
|
|
55
|
+
}
|
|
56
|
+
if (status.plan.id === target.id && (!live || sub?.interval === interval)) {
|
|
57
|
+
if (!sub?.cancel_at_period_end) return 'current';
|
|
58
|
+
}
|
|
59
|
+
if (!status.checkout_available || !priceFor(target, interval)) return 'unavailable';
|
|
60
|
+
return live ? 'change' : 'checkout';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export type BadgeTone = 'default' | 'secondary' | 'destructive' | 'outline';
|
|
64
|
+
|
|
65
|
+
export function statusTone(status: SubscriptionStatus | null): BadgeTone {
|
|
66
|
+
switch (status) {
|
|
67
|
+
case 'past_due':
|
|
68
|
+
case 'unpaid':
|
|
69
|
+
return 'destructive';
|
|
70
|
+
case 'active':
|
|
71
|
+
case 'trialing':
|
|
72
|
+
return 'default';
|
|
73
|
+
case null:
|
|
74
|
+
return 'outline';
|
|
75
|
+
default:
|
|
76
|
+
return 'secondary';
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export const STATUS_LABEL: Record<SubscriptionStatus, string> = {
|
|
81
|
+
trialing: 'Trial',
|
|
82
|
+
active: 'Active',
|
|
83
|
+
past_due: 'Payment failed',
|
|
84
|
+
unpaid: 'Unpaid',
|
|
85
|
+
canceled: 'Canceled',
|
|
86
|
+
incomplete: 'Incomplete',
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
export function statusLabel(status: SubscriptionStatus | null): string {
|
|
90
|
+
return status ? STATUS_LABEL[status] : 'No subscription';
|
|
91
|
+
}
|