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
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
+ });
@@ -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
+ }