diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f502fd00..1c323f8d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -226,6 +226,13 @@ jobs: cd ui/packages/nextjs npm publish --provenance --access public ${{ github.event.inputs.dry_run == 'true' && '--dry-run' || '' }} + # Trusted publishing only works for a package that already exists on + # npm, so the first release of this one is published by hand. + - name: Publish @authsome/plugin-subscription + run: | + cd ui/packages/plugin-subscription + npm publish --provenance --access public ${{ github.event.inputs.dry_run == 'true' && '--dry-run' || '' }} + # ─── Flutter/pub.dev Release ───────────────────────────────────────── # Authentication is OIDC, provisioned inside the reusable workflow -- there is # no pub credential to configure here. Two consequences: @@ -238,6 +245,11 @@ jobs: # 2. pub.dev rejects automated publishing from anything but a tag push, so a # workflow_dispatch run can only ever rehearse. dry-run is forced on for # those, which is also what the dry_run input maps to on a tag push. + # + # Automated publishing can only be switched on for a package that already + # exists on pub.dev, so authsome_subscription and + # authsome_subscription_flutter go up by hand (`dart pub publish`) once, + # then get the tag pattern above. Until then their step here fails. flutter-release: name: Flutter Release needs: [prepare] @@ -252,5 +264,5 @@ jobs: with: # Ordered: authsome_flutter depends on authsome_core, and # authsome_flutter_ui depends on authsome_flutter. - packages: '["flutter/packages/authsome_core", "flutter/packages/authsome_flutter", "flutter/packages/authsome_flutter_ui"]' + packages: '["flutter/packages/authsome_core", "flutter/packages/authsome_flutter", "flutter/packages/authsome_flutter_ui", "flutter/packages/authsome_subscription", "flutter/packages/authsome_subscription_flutter"]' dry-run: ${{ github.event_name != 'push' || github.event.inputs.dry_run == 'true' }} diff --git a/api/me_active_org_test.go b/api/me_active_org_test.go index 61480fa2..cb37330f 100644 --- a/api/me_active_org_test.go +++ b/api/me_active_org_test.go @@ -44,4 +44,9 @@ func TestGetMe_ActiveOrgID(t *testing.T) { withOrg := *sess withOrg.OrgID = orgID assert.Equal(t, orgID.String(), get(middleware.WithSession(base, &withOrg))["active_org_id"]) + + // A JWT carries the organization in its claims and there's no session + // row; the auth middleware puts the org on the context instead. + jwtOrg := id.NewOrgID() + assert.Equal(t, jwtOrg.String(), get(middleware.WithOrgID(base, jwtOrg))["active_org_id"]) } diff --git a/docs/content/docs/plugins/subscription.mdx b/docs/content/docs/plugins/subscription.mdx index 46af8af9..f437fa44 100644 --- a/docs/content/docs/plugins/subscription.mdx +++ b/docs/content/docs/plugins/subscription.mdx @@ -147,6 +147,76 @@ route a tenant's billing writes to one of them, or check for an existing subscri webhook. +## In React + +The client half ships as `@authsome/plugin-subscription`. Add it to your provider the way you added the server plugin to the engine: + +```tsx +import { subscription } from "@authsome/plugin-subscription"; +import { useSubscription, Entitled } from "@authsome/plugin-subscription/react"; +import { PricingTable, CurrentPlanCard } from "@authsome/plugin-subscription/components"; + + + + + +function Billing() { + const { plan, subscription, changePlan, isPending } = useSubscription(); + return ( + <> + + + + ); +} + +}> + + +``` + +Everything reading the subscription shares one cache. A write updates it, a sign-out clears it, and switching organization reloads it. `` shows the fallback until it has a positive answer, so loading or an outage never opens a paid feature. + +## In Flutter + +Two packages: `authsome_subscription` (pure Dart: models and state) and `authsome_subscription_flutter` (widgets). + +```dart +AuthProvider( + config: AuthConfig(baseUrl: url, plugins: const [SubscriptionPlugin()]), + child: const MyApp(), +); + +const CurrentPlanCard(); +PricingTable(onCheckout: (uri) => launchUrl(uri)); +const EntitlementGate(feature: 'sso', fallback: UpgradePrompt(), child: SsoSettings()); + +final billing = context.subscription; +final result = await billing.changePlan('team'); +``` + +The widgets hand checkout and portal URLs back to you (`onCheckout`, `onOpen`), so the packages don't depend on a URL launcher. On both platforms the components show plain words when a write fails and pass the server's error to `onError`. + +`CurrentPlanCard` asks before it cancels and says when the plan ends. If the server doesn't have the subscription plugin, the client packages say so once in the console during development, and every entitlement check fails closed. + +The billing methods on the core `ui-core` and `authsome_core` clients still work, and are marked deprecated in favour of these packages. + +### Another tenant's billing + +Support staff who hold `billing:manage` can look after a customer's billing from the same components. Pass the customer's tenant and every read and write carries it as `tenant_id`: + +```tsx + +const { subscription } = useSubscription({ tenantId: customer.orgId }); +``` + +```dart +CurrentPlanCard(tenantId: customer.orgId); +final billing = context.subscriptionFor(customer.orgId); +``` + +Underneath that's `api.forTenant(id)`, which returns the same API with its own cache. It keeps its tenant when you switch organization and clears when you sign out. The server refuses anyone without `billing:manage`. + ## Operator routes Plans, coupons, invoices and the ID-based subscription routes (`/subscriptions/:subId/...`) diff --git a/flutter/packages/authsome_core/lib/src/generated/api_client.dart b/flutter/packages/authsome_core/lib/src/generated/api_client.dart index 5a460821..83528f36 100644 --- a/flutter/packages/authsome_core/lib/src/generated/api_client.dart +++ b/flutter/packages/authsome_core/lib/src/generated/api_client.dart @@ -1081,6 +1081,7 @@ class AuthClient { /// List coupons /// GET /v1/billing/coupons + @Deprecated('Operator route. Call it from your server with the authsome Dart SDK, or use authsome_subscription for tenant billing.') Future listCoupons({required String appId, required String token}) async { final path = '/v1/billing/coupons'; // A list of pairs rather than a map, because a query string may carry the @@ -1101,6 +1102,7 @@ class AuthClient { /// Create coupon /// POST /v1/billing/coupons + @Deprecated('Operator route. Call it from your server with the authsome Dart SDK, or use authsome_subscription for tenant billing.') Future createCoupon({required CreateCouponRequest body, required String token}) async { final path = '/v1/billing/coupons'; final res = await _request( @@ -1114,6 +1116,7 @@ class AuthClient { /// Delete coupon /// DELETE /v1/billing/coupons/{couponId} + @Deprecated('Operator route. Call it from your server with the authsome Dart SDK, or use authsome_subscription for tenant billing.') Future deleteCoupon({required String couponId, required String token}) async { final path = '/v1/billing/coupons/$couponId'; await _request( @@ -1125,6 +1128,7 @@ class AuthClient { /// List your entitlements /// GET /v1/billing/entitlements + @Deprecated('Use the authsome_subscription package instead.') Future listEntitlements({required String token, String? tenantId}) async { final path = '/v1/billing/entitlements'; // A list of pairs rather than a map, because a query string may carry the @@ -1145,6 +1149,7 @@ class AuthClient { /// Check feature entitlement /// GET /v1/billing/entitlements/{featureKey} + @Deprecated('Operator route. Call it from your server with the authsome Dart SDK, or use authsome_subscription for tenant billing.') Future checkEntitlement({required String featureKey, required String tenantId, required String token, String? appId}) async { final path = '/v1/billing/entitlements/$featureKey'; // A list of pairs rather than a map, because a query string may carry the @@ -1166,6 +1171,7 @@ class AuthClient { /// List invoices /// GET /v1/billing/invoices + @Deprecated('Operator route. Call it from your server with the authsome Dart SDK, or use authsome_subscription for tenant billing.') Future listInvoices({required String appId, required String tenantId, required String token}) async { final path = '/v1/billing/invoices'; // A list of pairs rather than a map, because a query string may carry the @@ -1187,6 +1193,7 @@ class AuthClient { /// Get invoice /// GET /v1/billing/invoices/{invoiceId} + @Deprecated('Operator route. Call it from your server with the authsome Dart SDK, or use authsome_subscription for tenant billing.') Future getInvoice({required String invoiceId, required String token}) async { final path = '/v1/billing/invoices/$invoiceId'; final res = await _request( @@ -1199,6 +1206,7 @@ class AuthClient { /// Mark invoice as paid /// POST /v1/billing/invoices/{invoiceId}/pay + @Deprecated('Operator route. Call it from your server with the authsome Dart SDK, or use authsome_subscription for tenant billing.') Future markInvoicePaid({required String invoiceId, required MarkInvoicePaidRequest body, required String token}) async { final path = '/v1/billing/invoices/$invoiceId/pay'; await _request( @@ -1211,6 +1219,7 @@ class AuthClient { /// Void an invoice /// POST /v1/billing/invoices/{invoiceId}/void + @Deprecated('Operator route. Call it from your server with the authsome Dart SDK, or use authsome_subscription for tenant billing.') Future voidInvoice({required String invoiceId, required VoidInvoiceRequest body, required String token}) async { final path = '/v1/billing/invoices/$invoiceId/void'; await _request( @@ -1223,6 +1232,7 @@ class AuthClient { /// List billing plans /// GET /v1/billing/plans + @Deprecated('Operator route. Call it from your server with the authsome Dart SDK, or use authsome_subscription for tenant billing.') Future listBillingPlans({required String appId, required String token, String? status, bool? includeAddons}) async { final path = '/v1/billing/plans'; // A list of pairs rather than a map, because a query string may carry the @@ -1245,6 +1255,7 @@ class AuthClient { /// Create billing plan /// POST /v1/billing/plans + @Deprecated('Operator route. Call it from your server with the authsome Dart SDK, or use authsome_subscription for tenant billing.') Future createBillingPlan({required CreatePlanRequest body, required String token}) async { final path = '/v1/billing/plans'; final res = await _request( @@ -1258,6 +1269,7 @@ class AuthClient { /// Get billing plan /// GET /v1/billing/plans/{planId} + @Deprecated('Operator route. Call it from your server with the authsome Dart SDK, or use authsome_subscription for tenant billing.') Future getBillingPlan({required String planId, required String token}) async { final path = '/v1/billing/plans/$planId'; final res = await _request( @@ -1270,6 +1282,7 @@ class AuthClient { /// Activate a billing plan /// POST /v1/billing/plans/{planId}/activate + @Deprecated('Operator route. Call it from your server with the authsome Dart SDK, or use authsome_subscription for tenant billing.') Future activateBillingPlan({required String planId, required String token}) async { final path = '/v1/billing/plans/$planId/activate'; await _request( @@ -1281,6 +1294,7 @@ class AuthClient { /// Archive a billing plan /// POST /v1/billing/plans/{planId}/archive + @Deprecated('Operator route. Call it from your server with the authsome Dart SDK, or use authsome_subscription for tenant billing.') Future archiveBillingPlan({required String planId, required String token}) async { final path = '/v1/billing/plans/$planId/archive'; await _request( @@ -1292,6 +1306,7 @@ class AuthClient { /// Open the billing portal /// POST /v1/billing/portal + @Deprecated('Use the authsome_subscription package instead.') Future createBillingPortalSession({required PortalRequest body, required String token}) async { final path = '/v1/billing/portal'; final res = await _request( @@ -1305,6 +1320,7 @@ class AuthClient { /// Get your subscription /// GET /v1/billing/subscription + @Deprecated('Use the authsome_subscription package instead.') Future getCurrentSubscription({required String token, String? tenantId}) async { final path = '/v1/billing/subscription'; // A list of pairs rather than a map, because a query string may carry the @@ -1325,6 +1341,7 @@ class AuthClient { /// Subscribe to a plan /// POST /v1/billing/subscription + @Deprecated('Use the authsome_subscription package instead.') Future subscribe({required SubscribeRequest body, required String token}) async { final path = '/v1/billing/subscription'; final res = await _request( @@ -1338,6 +1355,7 @@ class AuthClient { /// Change plan or seats /// PATCH /v1/billing/subscription + @Deprecated('Use the authsome_subscription package instead.') Future updateSubscription({required UpdateSubscriptionRequest body, required String token}) async { final path = '/v1/billing/subscription'; final res = await _request( @@ -1351,6 +1369,7 @@ class AuthClient { /// Cancel your subscription /// POST /v1/billing/subscription/cancel + @Deprecated('Use the authsome_subscription package instead.') Future cancelCurrentSubscription({required CancelCurrentRequest body, required String token}) async { final path = '/v1/billing/subscription/cancel'; final res = await _request( @@ -1364,6 +1383,7 @@ class AuthClient { /// Keep your subscription /// POST /v1/billing/subscription/resume + @Deprecated('Use the authsome_subscription package instead.') Future resumeCurrentSubscription({required TenantRequest body, required String token}) async { final path = '/v1/billing/subscription/resume'; final res = await _request( @@ -1377,6 +1397,7 @@ class AuthClient { /// List subscriptions /// GET /v1/billing/subscriptions + @Deprecated('Operator route. Call it from your server with the authsome Dart SDK, or use authsome_subscription for tenant billing.') Future listSubscriptions({required String appId, required String tenantId, required String token, String? status}) async { final path = '/v1/billing/subscriptions'; // A list of pairs rather than a map, because a query string may carry the @@ -1399,6 +1420,7 @@ class AuthClient { /// Create subscription /// POST /v1/billing/subscriptions + @Deprecated('Operator route. Call it from your server with the authsome Dart SDK, or use authsome_subscription for tenant billing.') Future createSubscription({required CreateSubscriptionRequest body, required String token}) async { final path = '/v1/billing/subscriptions'; final res = await _request( @@ -1412,6 +1434,7 @@ class AuthClient { /// Get active subscription /// GET /v1/billing/subscriptions/active + @Deprecated('Operator route. Call it from your server with the authsome Dart SDK, or use authsome_subscription for tenant billing.') Future getActiveSubscription({required String appId, required String tenantId, required String token}) async { final path = '/v1/billing/subscriptions/active'; // A list of pairs rather than a map, because a query string may carry the @@ -1433,6 +1456,7 @@ class AuthClient { /// Cancel subscription /// POST /v1/billing/subscriptions/{subId}/cancel + @Deprecated('Operator route. Call it from your server with the authsome Dart SDK, or use authsome_subscription for tenant billing.') Future cancelSubscription({required String subId, required CancelSubscriptionRequest body, required String token}) async { final path = '/v1/billing/subscriptions/$subId/cancel'; await _request( @@ -1445,6 +1469,7 @@ class AuthClient { /// Change subscription plan /// POST /v1/billing/subscriptions/{subId}/change-plan + @Deprecated('Operator route. Call it from your server with the authsome Dart SDK, or use authsome_subscription for tenant billing.') Future changeSubscriptionPlan({required String subId, required ChangePlanRequest body, required String token}) async { final path = '/v1/billing/subscriptions/$subId/change-plan'; await _request( @@ -1457,6 +1482,7 @@ class AuthClient { /// Pause subscription /// POST /v1/billing/subscriptions/{subId}/pause + @Deprecated('Operator route. Call it from your server with the authsome Dart SDK, or use authsome_subscription for tenant billing.') Future pauseSubscription({required String subId, required String token}) async { final path = '/v1/billing/subscriptions/$subId/pause'; await _request( @@ -1468,6 +1494,7 @@ class AuthClient { /// Resume subscription /// POST /v1/billing/subscriptions/{subId}/resume + @Deprecated('Operator route. Call it from your server with the authsome Dart SDK, or use authsome_subscription for tenant billing.') Future resumeSubscription({required String subId, required String token}) async { final path = '/v1/billing/subscriptions/$subId/resume'; await _request( @@ -1479,6 +1506,7 @@ class AuthClient { /// Get usage summary /// GET /v1/billing/usage + @Deprecated('Operator route. Call it from your server with the authsome Dart SDK, or use authsome_subscription for tenant billing.') Future getUsageSummary({required String appId, required String tenantId, required String token}) async { final path = '/v1/billing/usage'; // A list of pairs rather than a map, because a query string may carry the diff --git a/flutter/packages/authsome_subscription/CHANGELOG.md b/flutter/packages/authsome_subscription/CHANGELOG.md new file mode 100644 index 00000000..62094844 --- /dev/null +++ b/flutter/packages/authsome_subscription/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 1.6.1 + +- First release. diff --git a/flutter/packages/authsome_subscription/LICENSE b/flutter/packages/authsome_subscription/LICENSE new file mode 100644 index 00000000..ba6a044b --- /dev/null +++ b/flutter/packages/authsome_subscription/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Xraph + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/flutter/packages/authsome_subscription/README.md b/flutter/packages/authsome_subscription/README.md new file mode 100644 index 00000000..124a6dde --- /dev/null +++ b/flutter/packages/authsome_subscription/README.md @@ -0,0 +1,47 @@ +# authsome_subscription + +The client half of authsome's `subscription` plugin, for Dart and Flutter. You get the tenant's subscription, its plan, seats and feature checks as typed models, and a stream of changes. For widgets, add [`authsome_subscription_flutter`](https://pub.dev/packages/authsome_subscription_flutter). + +```yaml +dependencies: + authsome_core: ^1.6.1 + authsome_subscription: ^1.6.1 +``` + +The server needs the plugin too: `authsome.WithPlugin(subscription.New(...))`. + +## Usage + +```dart +final auth = AuthManager(AuthConfig( + baseUrl: 'https://api.example.com', + plugins: const [SubscriptionPlugin()], +)); +await auth.initialize(); + +final billing = auth.plugin(); +await billing.load(); + +final sub = billing.snapshot.subscription.data; +if (sub != null && sub.willCancel) { + print('${sub.plan.name} ends on ${sub.cancelAt}'); +} +``` + +Writes update the snapshot, so anything listening to `billing.changes` sees the new state: + +```dart +final result = await billing.changePlan('team'); +switch (result) { + case SubscriptionChanged(:final subscription): + print('now on ${subscription.plan.name}'); + case CheckoutRequired(:final url): + await launchUrl(url); // payment first; nothing changed yet +} +``` + +`cancel()` cancels at the end of the period unless you pass `when: CancelWhen.now`, and `resume()` takes that back. `billing.entitlement('sso')` reads the cached check for a feature. + +The snapshot follows the session: signing out clears it, and signing in or switching organization reloads it. When the server doesn't have the subscription plugin, the resources come back `ResourceStatus.unavailable` without a request, and debug builds print one warning saying so. + +`billing.forTenant(orgId)` returns the same API pinned to another tenant, with its own cache, for support staff who hold `billing:manage`. It keeps its tenant across organization switches and clears on sign-out. diff --git a/flutter/packages/authsome_subscription/analysis_options.yaml b/flutter/packages/authsome_subscription/analysis_options.yaml new file mode 100644 index 00000000..5cb95a45 --- /dev/null +++ b/flutter/packages/authsome_subscription/analysis_options.yaml @@ -0,0 +1,7 @@ +include: package:lints/recommended.yaml + +linter: + rules: + prefer_single_quotes: true + sort_constructors_first: false + avoid_dynamic_calls: false diff --git a/flutter/packages/authsome_subscription/lib/authsome_subscription.dart b/flutter/packages/authsome_subscription/lib/authsome_subscription.dart new file mode 100644 index 00000000..af3d8917 --- /dev/null +++ b/flutter/packages/authsome_subscription/lib/authsome_subscription.dart @@ -0,0 +1,16 @@ +/// The client half of authsome's subscription plugin. +/// +/// ```dart +/// final auth = AuthManager(AuthConfig( +/// baseUrl: 'https://api.example.com', +/// plugins: [SubscriptionPlugin()], +/// )); +/// final billing = auth.plugin(); +/// await billing.load(); +/// ``` +/// +/// Flutter widgets are in `authsome_subscription_flutter`. +library authsome_subscription; + +export 'src/models.dart'; +export 'src/subscription_api.dart'; diff --git a/flutter/packages/authsome_subscription/lib/src/models.dart b/flutter/packages/authsome_subscription/lib/src/models.dart new file mode 100644 index 00000000..200db0c4 --- /dev/null +++ b/flutter/packages/authsome_subscription/lib/src/models.dart @@ -0,0 +1,265 @@ +/// Typed models for the subscription plugin, mapped from the server's JSON. +library; + +/// A subscription's state. A value from a newer server reads as [unknown]. +enum SubscriptionStatus { + active, + trialing, + pastDue, + canceled, + expired, + paused, + unknown; + + /// Parses the server's snake_case status. + static SubscriptionStatus parse(String value) => switch (value) { + 'active' => active, + 'trialing' => trialing, + 'past_due' => pastDue, + 'canceled' => canceled, + 'expired' => expired, + 'paused' => paused, + _ => unknown, + }; +} + +/// Currencies the ledger stores without a minor unit. It stores every other +/// currency with two places, so this list has to match the ledger's, not ISO 4217. +const _zeroDecimal = {'jpy', 'krw', 'vnd', 'clp', 'pyg', 'idr'}; + +const _symbols = {'usd': r'$', 'eur': '€', 'gbp': '£', 'jpy': '¥', 'inr': '₹', 'cad': r'CA$', 'aud': r'A$'}; + +/// An amount in the currency's minor unit (cents for usd). +class Money { + const Money({required this.amountMinor, required this.currency}); + + final int amountMinor; + final String currency; + + /// Decimal places the currency uses. + int get decimals => _zeroDecimal.contains(currency.toLowerCase()) ? 0 : 2; + + /// The amount in major units, e.g. 29.0 for 2900 usd cents. + double get major => decimals == 0 ? amountMinor.toDouble() : amountMinor / 100; + + /// A plain rendering such as `$29.00` or `NGN 10.50`. Use your own + /// NumberFormat for locale-aware output; [major] and [currency] are there + /// for that. + String format() { + // Integer arithmetic, so a large amount doesn't pick up float rounding. + final abs = amountMinor.abs(); + final amount = decimals == 0 + ? '$abs' + : '${abs ~/ 100}.${(abs % 100).toString().padLeft(2, '0')}'; + final sign = amountMinor < 0 ? '-' : ''; + final symbol = _symbols[currency.toLowerCase()]; + return symbol != null ? '$sign$symbol$amount' : '$sign${currency.toUpperCase()} $amount'; + } +} + +/// A plan's base price and how often it's charged. +class Price extends Money { + const Price({required super.amountMinor, required super.currency, required this.interval}); + + /// "monthly", "yearly", ... as the server names it. + final String interval; +} + +/// One feature a plan includes. A null [limit] means unlimited. +class PlanFeature { + const PlanFeature({ + required this.key, + required this.name, + required this.type, + required this.limit, + required this.softLimit, + this.period, + }); + + final String key; + final String name; + + /// "boolean", "metered" or "seat". + final String type; + final int? limit; + final String? period; + final bool softLimit; + + factory PlanFeature.fromJson(Map json) => PlanFeature( + key: json['key'] as String, + name: json['name'] as String, + type: json['type'] as String, + limit: (json['limit'] as num?)?.toInt(), + period: json['period'] as String?, + softLimit: json['soft_limit'] as bool? ?? false, + ); +} + +class Plan { + const Plan({ + required this.id, + required this.slug, + required this.name, + required this.description, + required this.status, + required this.trialDays, + required this.isAddon, + required this.price, + required this.features, + required this.metadata, + }); + + final String id; + final String slug; + final String name; + final String description; + final String status; + final int trialDays; + final bool isAddon; + + /// Null for a plan without a price. + final Price? price; + final List features; + final Map metadata; + + factory Plan.fromJson(Map json) { + final amount = json['amount_minor'] as num?; + return Plan( + id: json['id'] as String, + slug: json['slug'] as String, + name: json['name'] as String, + description: json['description'] as String? ?? '', + status: json['status'] as String, + trialDays: (json['trial_days'] as num?)?.toInt() ?? 0, + isAddon: json['is_addon'] as bool? ?? false, + price: amount == null + ? null + : Price( + amountMinor: amount.toInt(), + currency: json['currency'] as String, + interval: json['billing_period'] as String? ?? '', + ), + features: [ + for (final f in (json['features'] as List? ?? const [])) + PlanFeature.fromJson(Map.from(f as Map)), + ], + metadata: Map.from(json['metadata'] as Map? ?? const {}), + ); + } +} + +/// Seat use on a plan with a seat feature. A null [limit] means unlimited. +class Seats { + const Seats({required this.featureKey, required this.used, required this.limit}); + + final String featureKey; + final int used; + final int? limit; +} + +class Subscription { + const Subscription({ + required this.id, + required this.tenantId, + required this.status, + required this.plan, + required this.currentPeriodStart, + required this.currentPeriodEnd, + this.trialStart, + this.trialEnd, + this.cancelAt, + this.canceledAt, + this.pausedAt, + this.seats, + }); + + final String id; + final String tenantId; + final SubscriptionStatus status; + final Plan plan; + final DateTime currentPeriodStart; + final DateTime currentPeriodEnd; + final DateTime? trialStart; + final DateTime? trialEnd; + + /// When a cancellation scheduled for the period end takes effect. + final DateTime? cancelAt; + final DateTime? canceledAt; + final DateTime? pausedAt; + final Seats? seats; + + /// The tenant has the plan's features: active, trialing, or past due. + bool get isActive => + status == SubscriptionStatus.active || + status == SubscriptionStatus.trialing || + status == SubscriptionStatus.pastDue; + bool get isTrialing => status == SubscriptionStatus.trialing; + bool get isPaused => status == SubscriptionStatus.paused; + + /// A cancellation is scheduled; the plan runs until [cancelAt]. + bool get willCancel => cancelAt != null; + + /// Whole days of trial left, null when not trialing. + int? get trialDaysLeft { + final end = trialEnd; + if (!isTrialing || end == null) return null; + final left = end.difference(DateTime.now()).inMilliseconds / Duration.millisecondsPerDay; + return left <= 0 ? 0 : left.ceil(); + } + + factory Subscription.fromJson(Map json) { + DateTime? date(String key) { + final v = json[key] as String?; + return v == null ? null : DateTime.parse(v); + } + + final seats = json['seats'] as Map?; + return Subscription( + id: json['id'] as String, + tenantId: json['tenant_id'] as String, + status: SubscriptionStatus.parse(json['status'] as String), + plan: Plan.fromJson(Map.from(json['plan'] as Map)), + currentPeriodStart: DateTime.parse(json['current_period_start'] as String), + currentPeriodEnd: DateTime.parse(json['current_period_end'] as String), + trialStart: date('trial_start'), + trialEnd: date('trial_end'), + cancelAt: date('cancel_at'), + canceledAt: date('canceled_at'), + pausedAt: date('paused_at'), + seats: seats == null + ? null + : Seats( + featureKey: seats['feature_key'] as String, + used: (seats['used'] as num).toInt(), + limit: (seats['limit'] as num?)?.toInt(), + ), + ); + } +} + +/// Whether the tenant may use a feature, and how much of it is left. +class Entitlement { + const Entitlement({ + required this.allowed, + required this.used, + required this.limit, + required this.remaining, + this.reason, + }); + + final bool allowed; + final int used; + + /// Negative means unlimited, as ledger reports it. + final int limit; + final int remaining; + final String? reason; + + factory Entitlement.fromJson(Map json) => Entitlement( + allowed: json['allowed'] as bool, + used: (json['used'] as num).toInt(), + limit: (json['limit'] as num).toInt(), + remaining: (json['remaining'] as num).toInt(), + reason: json['reason'] as String?, + ); +} diff --git a/flutter/packages/authsome_subscription/lib/src/subscription_api.dart b/flutter/packages/authsome_subscription/lib/src/subscription_api.dart new file mode 100644 index 00000000..7fd6d6fc --- /dev/null +++ b/flutter/packages/authsome_subscription/lib/src/subscription_api.dart @@ -0,0 +1,377 @@ +import 'dart:async'; + +import 'package:authsome_core/authsome_core.dart'; + +import 'models.dart'; + +/// Where a piece of server state is. [unavailable] means the server doesn't +/// have the subscription plugin installed; treat it like a plan without +/// the feature. +enum ResourceStatus { idle, loading, ready, error, unavailable } + +/// One piece of server state. +class Resource { + const Resource(this.status, this.data, [this.error]); + + final ResourceStatus status; + final T data; + final Object? error; + + bool get isLoading => status == ResourceStatus.loading; +} + +/// Everything the plugin knows, as one immutable snapshot. +class SubscriptionSnapshot { + const SubscriptionSnapshot({ + required this.subscription, + required this.canManage, + required this.plans, + required this.entitlements, + }); + + SubscriptionSnapshot.initial() + : subscription = const Resource(ResourceStatus.idle, null), + canManage = false, + plans = const Resource(ResourceStatus.idle, []), + entitlements = const Resource(ResourceStatus.idle, {}); + + /// The tenant's subscription; data is null when it has none. + final Resource subscription; + + /// Whether the signed-in user may change the subscription. + final bool canManage; + + /// Plans the tenant can buy, cheapest first. + final Resource> plans; + + /// Every feature on the current plan, checked. + final Resource> entitlements; + + SubscriptionSnapshot copyWith({ + Resource? subscription, + bool? canManage, + Resource>? plans, + Resource>? entitlements, + }) => + SubscriptionSnapshot( + subscription: subscription ?? this.subscription, + canManage: canManage ?? this.canManage, + plans: plans ?? this.plans, + entitlements: entitlements ?? this.entitlements, + ); +} + +/// What a plan or seat change did. +sealed class ChangeResult { + const ChangeResult(); +} + +/// The change happened; here's the subscription now. +class SubscriptionChanged extends ChangeResult { + const SubscriptionChanged(this.subscription); + final Subscription subscription; +} + +/// Payment comes first: send the customer to [url]. Nothing changed yet. +class CheckoutRequired extends ChangeResult { + const CheckoutRequired(this.url); + final Uri url; +} + +/// When a cancellation takes effect. +enum CancelWhen { periodEnd, now } + +// State every scope of one plugin instance shares. +class _Shared { + final scopes = {}; + bool warned = false; +} + +const _unavailableWarning = + "authsome: the server doesn't have the subscription plugin, so billing reads as " + 'unavailable and every entitlement check fails closed. Register it on the server with ' + 'authsome.WithPlugin(subscription.New(...)), or drop SubscriptionPlugin from your client plugins.'; + +/// The subscription plugin's API, from `auth.plugin()` or, +/// in Flutter, `context.subscription`. +class SubscriptionApi { + SubscriptionApi(PluginContext ctx) : this._scoped(ctx, null, _Shared()); + + SubscriptionApi._scoped(this._ctx, this.tenantId, this._shared) { + // Follow the session: a sign-out forgets everything, and a sign-in or + // an organization switch reloads whatever had been loaded. + _events = _ctx.events.listen( + (event) { + switch (event) { + case AuthEvent.signedOut: + _generation++; + _set(SubscriptionSnapshot.initial()); + case AuthEvent.signedIn: + _reload(); + // A pinned tenant doesn't change with the organization. + case AuthEvent.orgChanged: + if (tenantId == null) _reload(); + case AuthEvent.sessionRefreshed: + break; + } + }, + // The manager closes its events when it's disposed; nothing will + // change after that, so neither does this stream. + onDone: () => unawaited(_changes.close()), + ); + } + + final PluginContext _ctx; + final _Shared _shared; + + /// The tenant this API is pinned to, or null for the session's own (its + /// active organization, or the user in user mode). + final String? tenantId; + + late final StreamSubscription _events; + final _changes = StreamController.broadcast(); + SubscriptionSnapshot _snapshot = SubscriptionSnapshot.initial(); + + // Bumped whenever the session or organization changes, so a response for + // the one the user just left is dropped instead of shown. + int _generation = 0; + + // The load in flight for the current generation, shared by every caller. + ({int gen, Future future})? _loading; + + /// The current snapshot. + SubscriptionSnapshot get snapshot => _snapshot; + + /// Every new snapshot. + Stream get changes => _changes.stream; + + void _set(SubscriptionSnapshot next) { + _snapshot = next; + if (!_changes.isClosed) _changes.add(next); + } + + /// The same API for another tenant, with its own cache. For operators who + /// hold billing:manage and look after customers' billing; the server + /// refuses anyone else. Calling it twice with one ID returns one instance. + SubscriptionApi forTenant(String tenantId) => + _shared.scopes.putIfAbsent(tenantId, () => SubscriptionApi._scoped(_ctx, tenantId, _shared)); + + // A pinned scope names its tenant on every request; the session's scope + // leaves it to the server. + Map? get _query => tenantId == null ? null : {'tenant_id': tenantId!}; + Map _scoped(Map body) => + tenantId == null ? body : {...body, 'tenant_id': tenantId}; + + bool get _unavailable { + if (_ctx.isServerEnabled != false) return false; + if (!_shared.warned) { + _shared.warned = true; + // Debug builds only: asserts are stripped from release builds. + assert(() { + // ignore: avoid_print + print(_unavailableWarning); + return true; + }()); + } + return true; + } + bool get _signedIn => _ctx.state is AuthAuthenticated; + + // A new session or organization: whatever was shown belongs to the old + // one, so clear it before anyone can act on it, then load again. + void _reload() { + _generation++; + if (_snapshot.subscription.status == ResourceStatus.idle) return; + _set(_snapshot.copyWith( + subscription: const Resource(ResourceStatus.loading, null), + entitlements: const Resource(ResourceStatus.loading, {}), + canManage: false, + )); + unawaited(load()); + } + + /// Loads the subscription and its entitlements. Calls made while a load + /// is running share it. + Future load() { + final current = _loading; + if (current != null && current.gen == _generation) return current.future; + final gen = _generation; + final future = _load(gen).whenComplete(() { + if (_loading?.gen == gen) _loading = null; + }); + _loading = (gen: gen, future: future); + return future; + } + + Future _load(int gen) async { + if (_unavailable) { + _set(_snapshot.copyWith( + subscription: const Resource(ResourceStatus.unavailable, null), + entitlements: const Resource(ResourceStatus.unavailable, {}), + )); + return; + } + if (!_signedIn) return; + _set(_snapshot.copyWith( + subscription: Resource(ResourceStatus.loading, _snapshot.subscription.data), + )); + final entitlements = _loadEntitlements(gen); + try { + final res = await _ctx.request('GET', '/v1/billing/subscription', query: _query) as Map; + if (gen != _generation) return; + final sub = res['subscription'] as Map?; + _set(_snapshot.copyWith( + subscription: Resource( + ResourceStatus.ready, + sub == null ? null : Subscription.fromJson(Map.from(sub)), + ), + canManage: res['can_manage'] as bool? ?? false, + )); + } catch (error) { + if (gen != _generation) return; + _set(_snapshot.copyWith( + subscription: Resource(ResourceStatus.error, _snapshot.subscription.data, error), + )); + } + await entitlements; + } + + Future _loadEntitlements(int gen) async { + if (gen != _generation) return; + _set(_snapshot.copyWith( + entitlements: Resource(ResourceStatus.loading, _snapshot.entitlements.data), + )); + try { + final res = await _ctx.request('GET', '/v1/billing/entitlements', query: _query) as Map; + if (gen != _generation) return; + final raw = Map.from(res['entitlements'] as Map? ?? const {}); + _set(_snapshot.copyWith( + entitlements: Resource(ResourceStatus.ready, { + for (final e in raw.entries) + e.key: Entitlement.fromJson(Map.from(e.value as Map)), + }), + )); + } catch (error) { + if (gen != _generation) return; + _set(_snapshot.copyWith( + entitlements: Resource(ResourceStatus.error, _snapshot.entitlements.data, error), + )); + } + } + + /// Loads the plans the tenant can buy. + Future loadPlans() async { + if (_unavailable) { + _set(_snapshot.copyWith(plans: const Resource(ResourceStatus.unavailable, []))); + return; + } + // /plans is app-wide and wants the app's ID: the client config carries + // it, and so does the signed-in user. + final state = _ctx.state; + final user = state is AuthAuthenticated ? state.user : null; + final appId = _ctx.clientConfig?.appId ?? (user is User ? user.appId : null); + final gen = _generation; + _set(_snapshot.copyWith(plans: Resource(ResourceStatus.loading, _snapshot.plans.data))); + try { + final res = await _ctx.request('GET', '/v1/billing/plans', query: {'app_id': appId}) as Map; + if (gen != _generation) return; + _set(_snapshot.copyWith( + plans: Resource(ResourceStatus.ready, [ + for (final p in (res['plans'] as List? ?? const [])) + Plan.fromJson(Map.from(p as Map)), + ]), + )); + } catch (error) { + if (gen != _generation) return; + _set(_snapshot.copyWith(plans: Resource(ResourceStatus.error, _snapshot.plans.data, error))); + } + } + + /// Starts a subscription on a plan, by slug or ID. + Future subscribe(String plan, {int? seats, String? returnUrl, String? cancelUrl}) => + _change('POST', _body({'plan': plan}, seats, returnUrl, cancelUrl)); + + /// Moves the subscription to another plan, by slug or ID. + Future changePlan(String plan, {int? seats, String? returnUrl, String? cancelUrl}) => + _change('PATCH', _body({'plan': plan}, seats, returnUrl, cancelUrl)); + + /// Changes the seat count on the current plan. + Future setSeats(int seats, {String? returnUrl, String? cancelUrl}) => + _change('PATCH', _body({}, seats, returnUrl, cancelUrl)); + + /// Cancels at the end of the period (the default) or now. + Future cancel({CancelWhen when = CancelWhen.periodEnd}) => _mutate( + '/v1/billing/subscription/cancel', + {'when': when == CancelWhen.now ? 'now' : 'period_end'}, + ); + + /// Takes back a cancellation scheduled for the end of the period. + Future resume() => _mutate('/v1/billing/subscription/resume', {}); + + /// A link to the payment provider's billing portal. + Future openPortal({String? returnUrl}) async { + final res = await _ctx.request('POST', '/v1/billing/portal', + body: _scoped({if (returnUrl != null) 'return_url': returnUrl})) as Map; + return Uri.parse(res['url'] as String); + } + + /// The cached check for a feature, null when it isn't on the plan or not loaded. + Entitlement? entitlement(String feature) => _snapshot.entitlements.data[feature]; + + Map _body(Map base, int? seats, String? returnUrl, String? cancelUrl) => { + ...base, + if (seats != null) 'seats': seats, + if (returnUrl != null) 'return_url': returnUrl, + if (cancelUrl != null) 'cancel_url': cancelUrl, + }; + + Future _change(String method, Map body) async { + final gen = _generation; + final res = await _ctx.request(method, '/v1/billing/subscription', body: _scoped(body)) as Map; + final url = res['checkout_url'] as String?; + if (url != null && url.isNotEmpty) return CheckoutRequired(Uri.parse(url)); + return SubscriptionChanged(_commit(res, gen)); + } + + Future _mutate(String path, Map body) async { + final gen = _generation; + final res = await _ctx.request('POST', path, body: _scoped(body)) as Map; + return _commit(res, gen); + } + + // Writes answer with the new subscription. It goes straight into the + // cache, and the entitlements are rechecked since the plan may have moved. + // A write that lands after the user switched org or signed out still + // returns its result, but the cache now belongs to someone else. + Subscription _commit(Map res, int gen) { + final json = res['subscription'] as Map?; + if (json == null) { + throw StateError('authsome: the server answered without a subscription'); + } + final sub = Subscription.fromJson(Map.from(json)); + if (gen == _generation) { + _set(_snapshot.copyWith(subscription: Resource(ResourceStatus.ready, sub))); + unawaited(_loadEntitlements(gen)); + } + return sub; + } + + /// Stops following the session. The plugin's manager calls nothing on + /// dispose today, so this is for tests and long-lived isolates. + Future close() async { + await _events.cancel(); + if (!_changes.isClosed) await _changes.close(); + } +} + +/// The subscription client plugin. Register it in [AuthConfig.plugins] and +/// read it with `auth.plugin()`. +class SubscriptionPlugin extends AuthPlugin { + const SubscriptionPlugin(); + + @override + String get name => 'subscription'; + + @override + SubscriptionApi init(PluginContext ctx) => SubscriptionApi(ctx); +} diff --git a/flutter/packages/authsome_subscription/pubspec.yaml b/flutter/packages/authsome_subscription/pubspec.yaml new file mode 100644 index 00000000..5d084cd2 --- /dev/null +++ b/flutter/packages/authsome_subscription/pubspec.yaml @@ -0,0 +1,19 @@ +name: authsome_subscription +description: Client plugin for authsome's subscription plugin. Plans, subscriptions, seats and entitlements for Dart and Flutter apps. +version: 1.6.1 +homepage: https://github.com/xraph/authsome +repository: https://github.com/xraph/authsome/tree/main/flutter/packages/authsome_subscription +issue_tracker: https://github.com/xraph/authsome/issues + +environment: + sdk: ^3.6.0 + +resolution: workspace + +dependencies: + authsome_core: ^1.6.1 + +dev_dependencies: + http: ^1.2.0 + lints: ^4.0.0 + test: ^1.25.0 diff --git a/flutter/packages/authsome_subscription/test/subscription_api_test.dart b/flutter/packages/authsome_subscription/test/subscription_api_test.dart new file mode 100644 index 00000000..a8c4b866 --- /dev/null +++ b/flutter/packages/authsome_subscription/test/subscription_api_test.dart @@ -0,0 +1,455 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:authsome_core/authsome_core.dart'; +import 'package:authsome_subscription/authsome_subscription.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:test/test.dart'; + +Map planJson(String slug, [Map overrides = const {}]) => { + 'id': 'plan_$slug', + 'name': '${slug[0].toUpperCase()}${slug.substring(1)}', + 'slug': slug, + 'description': '', + 'currency': 'usd', + 'status': 'active', + 'trial_days': 0, + 'features_count': 1, + 'is_addon': false, + 'app_id': 'app_1', + 'base_amount': '29.00', + 'billing_period': 'monthly', + 'amount_minor': 2900, + 'features': [ + {'key': 'sso', 'name': 'SSO', 'type': 'boolean', 'limit': 1, 'soft_limit': false}, + ], + ...overrides, + }; + +Map subJson([Map overrides = const {}]) => { + 'id': 'sub_1', + 'tenant_id': 'org_1', + 'status': 'active', + 'plan': planJson('pro'), + 'current_period_start': '2026-11-01T00:00:00Z', + 'current_period_end': '2026-12-01T00:00:00Z', + 'trial_start': null, + 'trial_end': null, + 'cancel_at': null, + 'canceled_at': null, + 'paused_at': null, + 'app_id': 'app_1', + ...overrides, + }; + +final _me = { + 'id': 'usr_1', + 'app_id': 'app_user', + 'env_id': 'env_1', + 'email': 'a@test', + 'email_verified': true, + 'first_name': 'A', + 'last_name': 'B', + 'banned': false, + 'phone_verified': false, + 'roles': [], + 'active_org_id': 'org_1', + 'created_at': '2026-01-01T00:00:00Z', + 'updated_at': '2026-01-01T00:00:00Z', +}; + +typedef Handler = FutureOr Function(http.Request req); + +http.Response _json(Object? body, [int status = 200]) => http.Response(jsonEncode(body), status); + +({AuthManager manager, Map routes, List calls, SubscriptionApi Function() api}) + _setup({List? serverPlugins, String? configAppId}) { + final routes = { + 'GET /v1/me': (_) => _json(_me), + 'POST /v1/signout': (_) => _json({'status': 'ok'}), + 'GET /v1/billing/subscription': (_) => _json({'subscription': subJson(), 'can_manage': true}), + 'GET /v1/billing/entitlements': (_) => _json({ + 'entitlements': { + 'sso': {'allowed': true, 'feature': 'sso', 'used': 0, 'limit': 1, 'remaining': 1}, + }, + }), + }; + final calls = []; + final storage = MemoryTokenStorage() + ..setItem('authsome:session', jsonEncode({ + 'session_token': 'tok', + 'refresh_token': 'ref', + 'expires_at': DateTime.now().add(const Duration(hours: 1)).toUtc().toIso8601String(), + })); + final manager = AuthManager(AuthConfig( + baseUrl: 'https://api.test', + storage: storage, + plugins: [SubscriptionPlugin()], + initialClientConfig: serverPlugins == null && configAppId == null + ? null + : ClientConfig(supportedPlugins: serverPlugins, appId: configAppId), + httpClient: MockClient((req) async { + final key = '${req.method} ${req.url.path}'; + calls.add('$key${req.url.hasQuery ? '?${req.url.query}' : ''}'); + final handler = routes[key]; + if (handler == null) return _json({'error': 'no route $key'}, 404); + return handler(req); + }), + )); + addTearDown(manager.dispose); + return (manager: manager, routes: routes, calls: calls, api: () => manager.plugin()); +} + +void main() { + group('models', () { + test('maps the server view into typed models', () async { + final s = _setup(); + s.routes['GET /v1/billing/subscription'] = (_) => _json({ + 'can_manage': false, + 'subscription': subJson({ + 'status': 'trialing', + 'trial_end': DateTime.now().add(const Duration(days: 3)).toUtc().toIso8601String(), + 'cancel_at': '2026-12-01T00:00:00Z', + 'seats': {'feature_key': 'seats', 'used': 3, 'limit': null}, + 'plan': planJson('team', { + 'features': [ + {'key': 'api', 'name': 'API', 'type': 'metered', 'limit': null, 'soft_limit': true, 'period': 'monthly'}, + ], + }), + }), + }); + await s.manager.initialize(); + await s.api().load(); + + final snap = s.api().snapshot; + final sub = snap.subscription.data!; + expect(snap.subscription.status, ResourceStatus.ready); + expect(snap.canManage, isFalse); + expect(sub.plan.slug, 'team'); + expect(sub.plan.price!.amountMinor, 2900); + expect(sub.plan.price!.interval, 'monthly'); + expect(sub.plan.features.single.limit, isNull); + expect(sub.plan.features.single.softLimit, isTrue); + expect(sub.seats!.used, 3); + expect(sub.seats!.limit, isNull); + expect(sub.status, SubscriptionStatus.trialing); + expect(sub.isTrialing, isTrue); + expect(sub.isActive, isTrue); + expect(sub.willCancel, isTrue); + expect(sub.cancelAt, DateTime.utc(2026, 12, 1)); + expect(sub.trialDaysLeft, 3); + }); + + test('keeps an unknown status from a newer server', () { + expect(SubscriptionStatus.parse('frozen'), SubscriptionStatus.unknown); + expect(SubscriptionStatus.parse('past_due'), SubscriptionStatus.pastDue); + }); + + test('formats money by the currency digits', () { + expect(const Money(amountMinor: 2900, currency: 'usd').format(), r'$29.00'); + expect(const Money(amountMinor: 500, currency: 'jpy').format(), '¥500'); + expect(const Money(amountMinor: 1050, currency: 'ngn').format(), 'NGN 10.50'); + }); + + test('uses the ledger currency digits', () { + // The ledger stores idr without a minor unit and every other + // currency outside its zero-decimal list with two places. + expect(const Money(amountMinor: 15000, currency: 'IDR').decimals, 0); + expect(const Money(amountMinor: 15000, currency: 'idr').format(), 'IDR 15000'); + expect(const Money(amountMinor: 1050, currency: 'bif').decimals, 2); + expect(const Money(amountMinor: 1050, currency: 'bif').format(), 'BIF 10.50'); + }); + + test('formats large and negative amounts exactly', () { + expect(const Money(amountMinor: 900719925474099, currency: 'usd').format(), r'$9007199254740.99'); + expect(const Money(amountMinor: -1005, currency: 'usd').format(), r'-$10.05'); + }); + }); + + group('loading', () { + test('loads the subscription, can_manage and entitlements', () async { + final s = _setup(); + await s.manager.initialize(); + await s.api().load(); + expect(s.api().snapshot.subscription.data!.id, 'sub_1'); + expect(s.api().snapshot.canManage, isTrue); + expect(s.api().snapshot.entitlements.status, ResourceStatus.ready); + expect(s.api().entitlement('sso')!.allowed, isTrue); + expect(s.api().entitlement('nope'), isNull); + }); + + test('is unavailable without asking when the server has no subscription plugin', () async { + final s = _setup(serverPlugins: ['organization']); + await s.manager.initialize(); + await s.api().load(); + expect(s.api().snapshot.subscription.status, ResourceStatus.unavailable); + expect(s.calls.where((c) => c.contains('/v1/billing')), isEmpty); + }); + + test('drops a response for the org the session has since left', () async { + final s = _setup(); + await s.manager.initialize(); + final gate = Completer(); + s.routes['GET /v1/billing/subscription'] = (_) async { + await gate.future; + return _json({'subscription': subJson(), 'can_manage': true}); + }; + final loading = s.api().load(); + + s.routes['POST /v1/me/switch-org'] = (_) => _json({'session_id': 's', 'org_id': 'org_2'}); + s.routes['GET /v1/billing/subscription'] = (_) => _json({'subscription': null, 'can_manage': true}); + await s.manager.switchOrg('org_2'); + gate.complete(); + await loading; + await Future.delayed(Duration.zero); + expect(s.api().snapshot.subscription.status, ResourceStatus.ready); + expect(s.api().snapshot.subscription.data, isNull); + }); + + test('shares one request between overlapping loads', () async { + final s = _setup(); + await s.manager.initialize(); + await Future.wait([s.api().load(), s.api().load()]); + expect(s.calls.where((c) => c == 'GET /v1/billing/subscription'), hasLength(1)); + expect(s.calls.where((c) => c == 'GET /v1/billing/entitlements'), hasLength(1)); + }); + + test('hides the old org while the new one loads', () async { + final s = _setup(); + await s.manager.initialize(); + await s.api().load(); + final gate = Completer(); + s.routes['POST /v1/me/switch-org'] = (_) => _json({'session_id': 's', 'org_id': 'org_2'}); + s.routes['GET /v1/billing/subscription'] = (_) async { + await gate.future; + return _json({'subscription': null, 'can_manage': false}); + }; + await s.manager.switchOrg('org_2'); + final snap = s.api().snapshot; + expect(snap.subscription.status, ResourceStatus.loading); + expect(snap.subscription.data, isNull); + expect(snap.canManage, isFalse); + expect(snap.entitlements.data, isEmpty); + gate.complete(); + }); + + test('drops plans that arrive after sign-out', () async { + final s = _setup(); + await s.manager.initialize(); + final gate = Completer(); + s.routes['GET /v1/billing/plans'] = (_) async { + await gate.future; + return _json({'plans': [planJson('pro')]}); + }; + final loading = s.api().loadPlans(); + await s.manager.signOut(); + gate.complete(); + await loading; + expect(s.api().snapshot.plans.status, ResourceStatus.idle); + expect(s.api().snapshot.plans.data, isEmpty); + }); + + test('ends the changes stream when the manager is disposed', () async { + final s = _setup(); + await s.manager.initialize(); + final done = Completer(); + s.api().changes.listen(null, onDone: done.complete); + s.manager.dispose(); + await done.future.timeout(const Duration(seconds: 1)); + }); + + test('resets on sign-out', () async { + final s = _setup(); + await s.manager.initialize(); + await s.api().load(); + await s.manager.signOut(); + expect(s.api().snapshot.subscription.status, ResourceStatus.idle); + expect(s.api().snapshot.subscription.data, isNull); + }); + + test('reads plans for the client config app, else the user app', () async { + final withConfig = _setup(configAppId: 'app_cfg'); + withConfig.routes['GET /v1/billing/plans'] = (_) => _json({ + 'plans': [planJson('free', {'amount_minor': null}), planJson('pro')], + }); + await withConfig.manager.initialize(); + await withConfig.api().loadPlans(); + expect(withConfig.calls, contains('GET /v1/billing/plans?app_id=app_cfg')); + expect(withConfig.api().snapshot.plans.data.map((p) => p.slug), ['free', 'pro']); + expect(withConfig.api().snapshot.plans.data.first.price, isNull); + + final fromUser = _setup(); + fromUser.routes['GET /v1/billing/plans'] = (_) => _json({'plans': []}); + await fromUser.manager.initialize(); + await fromUser.api().loadPlans(); + expect(fromUser.calls, contains('GET /v1/billing/plans?app_id=app_user')); + }); + }); + + group('writes', () { + test('writes a changed subscription into the cache', () async { + final s = _setup(); + await s.manager.initialize(); + await s.api().load(); + s.routes['PATCH /v1/billing/subscription'] = (req) { + expect(jsonDecode(req.body), {'plan': 'team', 'seats': 5}); + return _json({'subscription': subJson({'plan': planJson('team')})}); + }; + final result = await s.api().changePlan('team', seats: 5); + expect(result, isA()); + expect(s.api().snapshot.subscription.data!.plan.slug, 'team'); + }); + + test('leaves the cache alone when payment comes first', () async { + final s = _setup(); + await s.manager.initialize(); + await s.api().load(); + s.routes['POST /v1/billing/subscription'] = (_) => _json({'checkout_url': 'https://pay.example/1'}); + final result = await s.api().subscribe('business', returnUrl: 'https://app/billing'); + expect(result, isA()); + expect((result as CheckoutRequired).url, Uri.parse('https://pay.example/1')); + expect(s.api().snapshot.subscription.data!.plan.slug, 'pro'); + }); + + test('surfaces a refused write without touching the cache', () async { + final s = _setup(); + await s.manager.initialize(); + await s.api().load(); + s.routes['POST /v1/billing/subscription/cancel'] = (_) => _json({'error': 'nope'}, 403); + await expectLater(s.api().cancel(), throwsA(isA())); + expect(s.api().snapshot.subscription.data!.cancelAt, isNull); + }); + + test('cancels at the period end by default and resumes', () async { + final s = _setup(); + await s.manager.initialize(); + await s.api().load(); + s.routes['POST /v1/billing/subscription/cancel'] = (req) { + expect(jsonDecode(req.body), {'when': 'period_end'}); + return _json({'subscription': subJson({'cancel_at': '2026-12-01T00:00:00Z'})}); + }; + s.routes['POST /v1/billing/subscription/resume'] = (_) => _json({'subscription': subJson()}); + expect((await s.api().cancel()).willCancel, isTrue); + expect((await s.api().resume()).willCancel, isFalse); + }); + + test('drops a write that lands after an org switch', () async { + final s = _setup(); + await s.manager.initialize(); + await s.api().load(); + final gate = Completer(); + s.routes['PATCH /v1/billing/subscription'] = (_) async { + await gate.future; + return _json({'subscription': subJson({'plan': planJson('team')})}); + }; + final writing = s.api().changePlan('team'); + s.routes['POST /v1/me/switch-org'] = (_) => _json({'session_id': 's', 'org_id': 'org_2'}); + s.routes['GET /v1/billing/subscription'] = (_) => _json({'subscription': null, 'can_manage': true}); + await s.manager.switchOrg('org_2'); + // Let the new org's load finish first, so only the stale write is left. + await Future.delayed(const Duration(milliseconds: 10)); + expect(s.api().snapshot.subscription.status, ResourceStatus.ready); + gate.complete(); + final result = await writing; + await Future.delayed(Duration.zero); + // The caller still hears what happened to the org it wrote to. + expect((result as SubscriptionChanged).subscription.plan.slug, 'team'); + expect(s.api().snapshot.subscription.data, isNull); + }); + + test('drops a write that lands after sign-out', () async { + final s = _setup(); + await s.manager.initialize(); + await s.api().load(); + final gate = Completer(); + s.routes['POST /v1/billing/subscription/resume'] = (_) async { + await gate.future; + return _json({'subscription': subJson()}); + }; + final writing = s.api().resume(); + await s.manager.signOut(); + gate.complete(); + await writing; + expect(s.api().snapshot.subscription.status, ResourceStatus.idle); + expect(s.calls.where((c) => c == 'GET /v1/billing/entitlements'), hasLength(1)); + }); + + test('emits a snapshot on every change', () async { + final s = _setup(); + await s.manager.initialize(); + final seen = []; + s.api().changes.listen(seen.add); + await s.api().load(); + expect(seen, isNotEmpty); + }); + }); + + group('a server without the plugin', () { + Future> printed(Future Function() body) async { + final lines = []; + await runZoned(body, zoneSpecification: ZoneSpecification( + print: (self, parent, zone, line) => lines.add(line), + )); + return lines; + } + + test('warns once in debug, naming the fix', () async { + final lines = await printed(() async { + final s = _setup(serverPlugins: ['organization']); + await s.manager.initialize(); + await s.api().load(); + await s.api().load(); + await s.api().loadPlans(); + await s.api().forTenant('org_9').load(); + }); + expect(lines.where((l) => l.contains('subscription.New')), hasLength(1)); + }); + + test('stays quiet when the server has it', () async { + final lines = await printed(() async { + final s = _setup(serverPlugins: ['subscription']); + await s.manager.initialize(); + await s.api().load(); + }); + expect(lines, isEmpty); + }); + }); + + group('forTenant', () { + test('reads and writes another tenant, with its own cache', () async { + final s = _setup(); + Object? cancelBody; + s.routes['POST /v1/billing/subscription/cancel'] = (req) { + cancelBody = jsonDecode(req.body); + return _json({'subscription': subJson({'tenant_id': 'org_9', 'cancel_at': '2026-12-01T00:00:00Z'})}); + }; + await s.manager.initialize(); + final other = s.api().forTenant('org_9'); + expect(identical(s.api().forTenant('org_9'), other), isTrue); + expect(other.tenantId, 'org_9'); + expect(s.api().tenantId, isNull); + + await other.load(); + expect(s.calls, contains('GET /v1/billing/subscription?tenant_id=org_9')); + expect(s.calls, contains('GET /v1/billing/entitlements?tenant_id=org_9')); + expect(s.api().snapshot.subscription.status, ResourceStatus.idle); + + await other.cancel(); + expect(cancelBody, {'when': 'period_end', 'tenant_id': 'org_9'}); + expect(other.snapshot.subscription.data!.willCancel, isTrue); + }); + + test('keeps a pinned tenant across org switches and forgets it on sign-out', () async { + final s = _setup(); + await s.manager.initialize(); + final other = s.api().forTenant('org_9'); + await other.load(); + s.routes['POST /v1/me/switch-org'] = (_) => _json({'session_id': 's', 'org_id': 'org_2'}); + await s.manager.switchOrg('org_2'); + expect(other.snapshot.subscription.status, ResourceStatus.ready); + await s.manager.signOut(); + expect(other.snapshot.subscription.status, ResourceStatus.idle); + }); + }); +} diff --git a/flutter/packages/authsome_subscription_flutter/CHANGELOG.md b/flutter/packages/authsome_subscription_flutter/CHANGELOG.md new file mode 100644 index 00000000..62094844 --- /dev/null +++ b/flutter/packages/authsome_subscription_flutter/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 1.6.1 + +- First release. diff --git a/flutter/packages/authsome_subscription_flutter/LICENSE b/flutter/packages/authsome_subscription_flutter/LICENSE new file mode 100644 index 00000000..ba6a044b --- /dev/null +++ b/flutter/packages/authsome_subscription_flutter/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Xraph + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/flutter/packages/authsome_subscription_flutter/README.md b/flutter/packages/authsome_subscription_flutter/README.md new file mode 100644 index 00000000..f00ded4a --- /dev/null +++ b/flutter/packages/authsome_subscription_flutter/README.md @@ -0,0 +1,62 @@ +# authsome_subscription_flutter + +Material 3 widgets for authsome's `subscription` plugin: a pricing table, the current plan, usage, an entitlement gate and a billing portal button. + +```yaml +dependencies: + authsome_flutter: ^1.6.1 + authsome_subscription_flutter: ^1.6.1 +``` + +## Set it up + +```dart +AuthProvider( + config: AuthConfig( + baseUrl: 'https://api.example.com', + plugins: const [SubscriptionPlugin()], + ), + child: const MyApp(), +); +``` + +## Widgets + +```dart +const CurrentPlanCard(); + +PricingTable( + highlight: 'team', + onCheckout: (uri) => launchUrl(uri), +); + +const EntitlementGate( + feature: 'sso', + fallback: UpgradePrompt(), + child: SsoSettings(), +); + +const UsageMeter(feature: 'api_calls', label: 'API calls this month'); + +ManageBillingButton(onOpen: (uri) => launchUrl(uri)); +``` + +`PricingTable` and `CurrentPlanCard` show plain words when a change fails, such as "Couldn't change your plan. Try again.", and pass the server's error to `onError` if you want to log it. Leave out `onCheckout` and a paid plan can't be bought: the table says so and hands `onError` a `StateError`. + +`EntitlementGate` only shows its child when the plan includes the feature. While the check loads, on an error, or when the server doesn't have the plugin, you get the fallback. + +Opening a payment or portal page is left to you, so the package doesn't pull in a URL launcher. Pass `launchUrl` from url_launcher, or whatever you already use. + +`CurrentPlanCard` asks before it cancels. Every widget takes a `tenantId` for support staff who hold `billing:manage` and look after a customer's billing, and `context.subscriptionFor(id)` is the API for that tenant. + +For your own UI, `context.subscription` is the plugin's API and `SubscriptionBuilder` rebuilds from its snapshot: + +```dart +SubscriptionBuilder( + builder: (context, snap) { + final sub = snap.subscription.data; + if (snap.subscription.isLoading) return const CircularProgressIndicator(); + return Text(sub?.plan.name ?? 'Free'); + }, +); +``` diff --git a/flutter/packages/authsome_subscription_flutter/analysis_options.yaml b/flutter/packages/authsome_subscription_flutter/analysis_options.yaml new file mode 100644 index 00000000..f190a220 --- /dev/null +++ b/flutter/packages/authsome_subscription_flutter/analysis_options.yaml @@ -0,0 +1,10 @@ +analyzer: + exclude: + - build/** +include: package:flutter_lints/flutter.yaml + +linter: + rules: + prefer_single_quotes: true + sort_constructors_first: false + avoid_dynamic_calls: false diff --git a/flutter/packages/authsome_subscription_flutter/lib/authsome_subscription_flutter.dart b/flutter/packages/authsome_subscription_flutter/lib/authsome_subscription_flutter.dart new file mode 100644 index 00000000..f6152d1c --- /dev/null +++ b/flutter/packages/authsome_subscription_flutter/lib/authsome_subscription_flutter.dart @@ -0,0 +1,18 @@ +/// Flutter widgets for authsome's subscription plugin. +/// +/// ```dart +/// AuthProvider( +/// config: AuthConfig(baseUrl: url, plugins: const [SubscriptionPlugin()]), +/// child: const MyApp(), +/// ); +/// +/// const CurrentPlanCard(); +/// PricingTable(onCheckout: (uri) => launchUrl(uri)); +/// const EntitlementGate(feature: 'sso', fallback: UpgradePrompt(), child: SsoSettings()); +/// ``` +library authsome_subscription_flutter; + +export 'package:authsome_flutter/authsome_flutter.dart'; +export 'package:authsome_subscription/authsome_subscription.dart'; + +export 'src/subscription_widgets.dart'; diff --git a/flutter/packages/authsome_subscription_flutter/lib/src/subscription_widgets.dart b/flutter/packages/authsome_subscription_flutter/lib/src/subscription_widgets.dart new file mode 100644 index 00000000..86effa5e --- /dev/null +++ b/flutter/packages/authsome_subscription_flutter/lib/src/subscription_widgets.dart @@ -0,0 +1,482 @@ +import 'dart:async'; + +import 'package:authsome_flutter/authsome_flutter.dart'; +import 'package:authsome_subscription/authsome_subscription.dart'; +import 'package:flutter/material.dart'; + +/// Reaches the subscription plugin from the widget tree. +extension SubscriptionContext on BuildContext { + /// The subscription plugin's API. The plugin has to be in the + /// [AuthConfig.plugins] of the surrounding [AuthProvider]. + SubscriptionApi get subscription => AuthProvider.of(this).plugin(); + + /// The API for [tenantId], or the session's own when it's null. Passing a + /// tenant is for operators who hold billing:manage. + SubscriptionApi subscriptionFor(String? tenantId) => + tenantId == null ? subscription : subscription.forTenant(tenantId); +} + +/// Builds from the subscription plugin's snapshot and rebuilds when it +/// changes. Loads the subscription (and, with [loadPlans], the plans) the +/// first time it's built while signed in. Pass [tenantId] to read another +/// tenant than the session's (operators holding billing:manage only). +class SubscriptionBuilder extends StatefulWidget { + const SubscriptionBuilder({required this.builder, this.loadPlans = false, this.tenantId, super.key}); + + final Widget Function(BuildContext context, SubscriptionSnapshot snapshot) builder; + final bool loadPlans; + final String? tenantId; + + @override + State createState() => _SubscriptionBuilderState(); +} + +class _SubscriptionBuilderState extends State { + SubscriptionApi? _api; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _resolve(); + } + + @override + void didUpdateWidget(SubscriptionBuilder oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.tenantId != widget.tenantId) _resolve(); + } + + void _resolve() { + final auth = AuthProvider.of(context); + final api = context.subscriptionFor(widget.tenantId); + _api = api; + // AuthProvider is inherited, so this runs again when the user signs in. + if (!auth.isAuthenticated) return; + if (api.snapshot.subscription.status == ResourceStatus.idle) unawaited(api.load()); + if (widget.loadPlans && api.snapshot.plans.status == ResourceStatus.idle) { + unawaited(api.loadPlans()); + } + } + + @override + Widget build(BuildContext context) { + final api = _api!; + return StreamBuilder( + stream: api.changes, + initialData: api.snapshot, + builder: (context, _) => widget.builder(context, api.snapshot), + ); + } +} + +// Idle counts as loading only while signed in: SubscriptionBuilder is +// about to start the load. Signed out, nothing will, so idle means empty. +bool _isLoading(BuildContext context, Resource r) => + r.status == ResourceStatus.loading || + (r.status == ResourceStatus.idle && AuthProvider.of(context).isAuthenticated); + +/// Shows [child] only when the plan includes [feature]. Loading, an error, +/// or billing not being installed all show [fallback] (or [loading] while +/// the check loads), so a paywall never opens by accident. +class EntitlementGate extends StatelessWidget { + const EntitlementGate({ + required this.feature, + required this.child, + this.fallback = const SizedBox.shrink(), + this.loading, + this.tenantId, + super.key, + }); + + final String feature; + final String? tenantId; + final Widget child; + final Widget fallback; + final Widget? loading; + + @override + Widget build(BuildContext context) { + return SubscriptionBuilder(tenantId: tenantId, builder: (context, snap) { + final entitlements = snap.entitlements; + if (entitlements.status == ResourceStatus.ready && + entitlements.data[feature]?.allowed == true) { + return child; + } + if (_isLoading(context, entitlements)) return loading ?? fallback; + return fallback; + }); + } +} + +String _date(BuildContext context, DateTime date) => + MaterialLocalizations.of(context).formatShortDate(date.toLocal()); + +// Server messages are written for developers, so people see plain words +// and the error itself goes to the widget's onError. +void _showError(BuildContext context, String message) { + ScaffoldMessenger.maybeOf(context)?.showSnackBar(SnackBar(content: Text(message))); +} + +/// The plans the tenant can buy, cheapest first, with a button on each. +/// +/// Without [onSelected] a tap subscribes or changes plan. When the plan +/// needs paying for, [onCheckout] gets the payment page's URL; open it with +/// your launcher of choice, e.g. `launchUrl(uri)` from url_launcher. +class PricingTable extends StatefulWidget { + const PricingTable({ + this.onSelected, + this.onCheckout, + this.highlight, + this.returnUrl, + this.cancelUrl, + this.onError, + this.tenantId, + super.key, + }); + + final ValueChanged? onSelected; + + /// Another tenant than the session's, for operators holding billing:manage. + final String? tenantId; + + /// Gets the payment page URL when a plan needs paying for. Without it the + /// table can't take payment: it tells the user and passes a [StateError] + /// to [onError]. + final ValueChanged? onCheckout; + + /// Called with the error when a change fails; the table shows plain words. + final ValueChanged? onError; + + /// Plan slug to draw attention to. + final String? highlight; + final String? returnUrl; + final String? cancelUrl; + + @override + State createState() => _PricingTableState(); +} + +class _PricingTableState extends State { + bool _busy = false; + + Future _choose(SubscriptionApi api, Plan plan, bool hasSubscription) async { + final onSelected = widget.onSelected; + if (onSelected != null) { + onSelected(plan); + return; + } + setState(() => _busy = true); + try { + final result = hasSubscription + ? await api.changePlan(plan.slug, returnUrl: widget.returnUrl, cancelUrl: widget.cancelUrl) + : await api.subscribe(plan.slug, returnUrl: widget.returnUrl, cancelUrl: widget.cancelUrl); + if (result is CheckoutRequired && mounted) { + final onCheckout = widget.onCheckout; + if (onCheckout != null) { + onCheckout(result.url); + } else { + debugPrint('authsome: PricingTable got a payment page but has no onCheckout to open it'); + _showError(context, "Couldn't open the payment page."); + widget.onError?.call(StateError('Set PricingTable.onCheckout to open the payment page')); + } + } + } catch (error) { + if (mounted) _showError(context, "Couldn't change your plan. Try again."); + widget.onError?.call(error); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return SubscriptionBuilder( + loadPlans: true, + tenantId: widget.tenantId, + builder: (context, snap) { + if (_isLoading(context, snap.plans)) { + return const Center(child: Padding(padding: EdgeInsets.all(24), child: CircularProgressIndicator())); + } + if (snap.plans.status == ResourceStatus.error) { + return const Text("Couldn't load plans."); + } + final current = snap.subscription.data; + final showButtons = snap.canManage || widget.onSelected != null; + return Wrap( + spacing: 16, + runSpacing: 16, + children: [ + for (final plan in snap.plans.data) + SizedBox( + width: 240, + child: Card( + shape: plan.slug == widget.highlight + ? RoundedRectangleBorder( + side: BorderSide(color: theme.colorScheme.primary, width: 2), + borderRadius: BorderRadius.circular(12), + ) + : null, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row(children: [ + Expanded(child: Text(plan.name, style: theme.textTheme.titleMedium)), + if (plan.slug == widget.highlight) + Chip( + label: const Text('Recommended'), + visualDensity: VisualDensity.compact, + backgroundColor: theme.colorScheme.primaryContainer, + ), + ]), + const SizedBox(height: 8), + Text(plan.price?.format() ?? 'No charge', style: theme.textTheme.headlineSmall), + if (plan.price != null && plan.price!.interval.isNotEmpty) + Text('/ ${plan.price!.interval}', style: theme.textTheme.bodySmall), + if (plan.trialDays > 0 && current == null) ...[ + const SizedBox(height: 8), + Chip(label: Text('${plan.trialDays}-day trial')), + ], + const SizedBox(height: 8), + for (final f in plan.features) + Text( + f.type == 'boolean' + ? f.name + : '${f.name} ${f.limit == null ? 'unlimited' : 'up to ${f.limit}'}', + style: theme.textTheme.bodyMedium, + ), + if (showButtons) ...[ + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: current?.plan.id == plan.id + ? const OutlinedButton(onPressed: null, child: Text('Current plan')) + : FilledButton( + onPressed: _busy + ? null + : () => _choose(context.subscriptionFor(widget.tenantId), plan, current != null), + child: Text('Choose ${plan.name}'), + ), + ), + ], + ], + ), + ), + ), + ), + ], + ); + }, + ); + } +} + +/// The tenant's plan: name, price, when it renews or ends, seats, and +/// cancel or keep for someone who can manage billing. +class CurrentPlanCard extends StatefulWidget { + const CurrentPlanCard({this.onError, this.tenantId, super.key}); + + /// Another tenant than the session's, for operators holding billing:manage. + final String? tenantId; + + /// Called with the error when cancel or keep fails; the card shows plain words. + final ValueChanged? onError; + + @override + State createState() => _CurrentPlanCardState(); +} + +class _CurrentPlanCardState extends State { + bool _busy = false; + + Future _run(Future Function() write) async { + setState(() => _busy = true); + try { + await write(); + } catch (error) { + if (mounted) _showError(context, "Couldn't update your plan. Try again."); + widget.onError?.call(error); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + // Canceling is the one write here that's hard to take back once the + // period ends, so it asks first. + Future _confirmCancel(SubscriptionApi api, Subscription sub) async { + final yes = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Cancel your plan?'), + content: Text('You keep it until ${_date(context, sub.currentPeriodEnd)}.'), + actions: [ + TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Never mind')), + FilledButton( + style: FilledButton.styleFrom(backgroundColor: Theme.of(context).colorScheme.error), + onPressed: () => Navigator.pop(context, true), + child: const Text('Yes, cancel'), + ), + ], + ), + ); + if (yes == true && mounted) await _run(api.cancel); + } + + String _status(BuildContext context, Subscription sub) { + if (sub.isPaused) return 'Paused'; + if (sub.willCancel) return 'Ends on ${_date(context, sub.cancelAt!)}'; + if (sub.isTrialing && sub.trialEnd != null) return 'Trial ends on ${_date(context, sub.trialEnd!)}'; + if (sub.status == SubscriptionStatus.pastDue) return 'Payment is overdue'; + return 'Renews on ${_date(context, sub.currentPeriodEnd)}'; + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return SubscriptionBuilder(tenantId: widget.tenantId, builder: (context, snap) { + if (_isLoading(context, snap.subscription)) { + return const Card(child: Padding(padding: EdgeInsets.all(24), child: LinearProgressIndicator())); + } + if (snap.subscription.status == ResourceStatus.error) { + return const Text("Couldn't load your plan."); + } + final sub = snap.subscription.data; + if (sub == null) { + return const Card( + child: ListTile( + title: Text('No plan yet'), + subtitle: Text('Pick a plan to get started.'), + ), + ); + } + final api = context.subscriptionFor(widget.tenantId); + final seats = sub.seats; + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row(children: [ + Expanded(child: Text(sub.plan.name, style: theme.textTheme.titleLarge)), + if (sub.isTrialing) const Chip(label: Text('Trial')), + if (sub.status == SubscriptionStatus.pastDue) + Chip( + label: const Text('Past due'), + backgroundColor: theme.colorScheme.errorContainer, + ), + ]), + if (sub.plan.price != null) + Text('${sub.plan.price!.format()} / ${sub.plan.price!.interval}', + style: theme.textTheme.bodyMedium), + const SizedBox(height: 8), + Text(_status(context, sub)), + if (seats != null) + Text( + seats.limit == null ? '${seats.used} seats' : '${seats.used} of ${seats.limit} seats', + style: theme.textTheme.bodySmall, + ), + if (snap.canManage && !sub.isPaused) ...[ + const SizedBox(height: 12), + sub.willCancel + ? FilledButton( + onPressed: _busy ? null : () => _run(api.resume), + child: const Text('Keep plan'), + ) + : OutlinedButton( + onPressed: _busy ? null : () => _confirmCancel(api, sub), + child: const Text('Cancel plan'), + ), + ], + ], + ), + ), + ); + }); + } +} + +/// How much of a metered feature is used, against the plan's limit. +class UsageMeter extends StatelessWidget { + const UsageMeter({required this.feature, this.label, this.tenantId, super.key}); + + final String feature; + final String? tenantId; + + /// What to call it. Defaults to the feature key. + final String? label; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return SubscriptionBuilder(tenantId: tenantId, builder: (context, snap) { + if (_isLoading(context, snap.entitlements)) return const LinearProgressIndicator(); + final e = snap.entitlements.data[feature]; + if (e == null) return const SizedBox.shrink(); + final unlimited = e.limit < 0; + final value = unlimited || e.limit == 0 ? 0.0 : (e.used / e.limit).clamp(0.0, 1.0); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row(children: [ + Expanded(child: Text(label ?? feature)), + Text(unlimited ? '${e.used} used' : '${e.used} of ${e.limit}', style: theme.textTheme.bodySmall), + ]), + const SizedBox(height: 4), + LinearProgressIndicator( + value: value, + color: value >= 0.9 ? theme.colorScheme.error : null, + semanticsLabel: label ?? feature, + ), + ], + ); + }); + } +} + +/// Opens the payment provider's billing portal. Shown only to someone who +/// can manage billing. [onOpen] gets the portal URL; open it with your +/// launcher of choice. +class ManageBillingButton extends StatefulWidget { + const ManageBillingButton({required this.onOpen, this.returnUrl, this.child, this.tenantId, super.key}); + + final ValueChanged onOpen; + final String? tenantId; + final String? returnUrl; + final Widget? child; + + @override + State createState() => _ManageBillingButtonState(); +} + +class _ManageBillingButtonState extends State { + bool _busy = false; + + Future _open(SubscriptionApi api) async { + setState(() => _busy = true); + try { + widget.onOpen(await api.openPortal(returnUrl: widget.returnUrl)); + } catch (_) { + if (mounted) { + ScaffoldMessenger.maybeOf(context)?.showSnackBar( + const SnackBar(content: Text("Couldn't open the billing portal")), + ); + } + } finally { + if (mounted) setState(() => _busy = false); + } + } + + @override + Widget build(BuildContext context) { + return SubscriptionBuilder(tenantId: widget.tenantId, builder: (context, snap) { + if (!snap.canManage) return const SizedBox.shrink(); + return OutlinedButton( + onPressed: _busy ? null : () => _open(context.subscriptionFor(widget.tenantId)), + child: widget.child ?? const Text('Manage billing'), + ); + }); + } +} diff --git a/flutter/packages/authsome_subscription_flutter/pubspec.yaml b/flutter/packages/authsome_subscription_flutter/pubspec.yaml new file mode 100644 index 00000000..97816e45 --- /dev/null +++ b/flutter/packages/authsome_subscription_flutter/pubspec.yaml @@ -0,0 +1,24 @@ +name: authsome_subscription_flutter +description: Flutter widgets for authsome's subscription plugin. Pricing table, current plan card, usage meter, entitlement gate and billing portal button. +version: 1.6.1 +homepage: https://github.com/xraph/authsome +repository: https://github.com/xraph/authsome/tree/main/flutter/packages/authsome_subscription_flutter +issue_tracker: https://github.com/xraph/authsome/issues + +environment: + sdk: ^3.6.0 + flutter: ">=3.10.0" + +resolution: workspace + +dependencies: + flutter: + sdk: flutter + authsome_flutter: ^1.6.1 + authsome_subscription: ^1.6.1 + +dev_dependencies: + http: ^1.6.0 + flutter_lints: ^4.0.0 + flutter_test: + sdk: flutter diff --git a/flutter/packages/authsome_subscription_flutter/test/widgets_test.dart b/flutter/packages/authsome_subscription_flutter/test/widgets_test.dart new file mode 100644 index 00000000..b593d0eb --- /dev/null +++ b/flutter/packages/authsome_subscription_flutter/test/widgets_test.dart @@ -0,0 +1,333 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:authsome_subscription_flutter/authsome_subscription_flutter.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +Map planJson(String slug, [Map overrides = const {}]) => { + 'id': 'plan_$slug', + 'name': '${slug[0].toUpperCase()}${slug.substring(1)}', + 'slug': slug, + 'description': '', + 'currency': 'usd', + 'status': 'active', + 'trial_days': 0, + 'is_addon': false, + 'amount_minor': 2900, + 'billing_period': 'monthly', + 'features': [ + {'key': 'sso', 'name': 'SSO', 'type': 'boolean', 'limit': 1, 'soft_limit': false}, + ], + ...overrides, + }; + +Map subJson([Map overrides = const {}]) => { + 'id': 'sub_1', + 'tenant_id': 'org_1', + 'status': 'active', + 'plan': planJson('pro'), + 'current_period_start': '2026-11-01T12:00:00Z', + 'current_period_end': '2026-12-01T12:00:00Z', + 'cancel_at': null, + ...overrides, + }; + +typedef Handler = FutureOr Function(http.Request req); +http.Response _json(Object? body, [int status = 200]) => http.Response(jsonEncode(body), status); + +class _Harness { + _Harness() { + routes.addAll({ + 'GET /v1/me': (_) => _json({ + 'id': 'usr_1', 'app_id': 'app_1', 'env_id': 'env_1', 'email': 'a@test', + 'email_verified': true, 'first_name': 'A', 'last_name': 'B', 'banned': false, + 'phone_verified': false, 'roles': [], 'active_org_id': 'org_1', + 'created_at': '2026-01-01T00:00:00Z', 'updated_at': '2026-01-01T00:00:00Z', + }), + 'GET /v1/billing/subscription': (_) => _json({ + 'subscription': subJson({'seats': {'feature_key': 'seats', 'used': 3, 'limit': 10}}), + 'can_manage': true, + }), + 'GET /v1/billing/entitlements': (_) => _json({ + 'entitlements': { + 'sso': {'allowed': true, 'feature': 'sso', 'used': 2, 'limit': 10, 'remaining': 8}, + }, + }), + 'GET /v1/billing/plans': (_) => _json({ + 'plans': [ + planJson('free', {'amount_minor': null}), + planJson('pro'), + planJson('team', {'amount_minor': 9900}), + ], + }), + }); + } + + final routes = {}; + final calls = []; + + /// The same calls with their query strings. + final urls = []; + + Future signedIn() => _auth(withSession: true); + + Future signedOut() => _auth(withSession: false); + + Future _auth({required bool withSession}) async { + final storage = MemoryTokenStorage(); + if (withSession) { + await storage.setItem('authsome:session', jsonEncode({ + 'session_token': 'tok', + 'refresh_token': 'ref', + 'expires_at': DateTime.now().add(const Duration(hours: 1)).toUtc().toIso8601String(), + })); + } + final auth = AuthNotifier(AuthConfig( + baseUrl: 'https://api.test', + storage: storage, + plugins: const [SubscriptionPlugin()], + httpClient: MockClient((req) async { + final key = '${req.method} ${req.url.path}'; + calls.add(key); + urls.add('$key${req.url.hasQuery ? '?${req.url.query}' : ''}'); + final handler = routes[key]; + return handler == null ? _json({'error': 'no route $key'}, 404) : handler(req); + }), + )); + await auth.initialize(); + return auth; + } + + Future pump(WidgetTester tester, Widget child, {bool signedOut = false}) async { + final auth = await tester.runAsync(signedOut ? this.signedOut : signedIn); + await tester.pumpWidget(AuthProvider.test( + notifier: auth!, + child: MaterialApp(home: Scaffold(body: SingleChildScrollView(child: child))), + )); + await _settle(tester); + return auth; + } +} + +// The mock client answers on real async gaps, so settle with real time. +Future _settle(WidgetTester tester) async { + for (var i = 0; i < 5; i++) { + await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 10))); + await tester.pump(); + } +} + +void main() { + testWidgets('SubscriptionBuilder loads the subscription', (tester) async { + await _Harness().pump(tester, SubscriptionBuilder( + builder: (context, snap) => Text(snap.subscription.data?.plan.name ?? 'none'), + )); + expect(find.text('Pro'), findsOneWidget); + }); + + testWidgets('EntitlementGate shows the child only when the plan allows it', (tester) async { + final h = _Harness(); + await h.pump(tester, const Column(children: [ + EntitlementGate(feature: 'sso', fallback: Text('upgrade sso'), child: Text('sso settings')), + EntitlementGate(feature: 'audit', fallback: Text('upgrade audit'), child: Text('audit log')), + ])); + expect(find.text('sso settings'), findsOneWidget); + expect(find.text('audit log'), findsNothing); + expect(find.text('upgrade audit'), findsOneWidget); + }); + + testWidgets('EntitlementGate fails closed on an error', (tester) async { + final h = _Harness(); + h.routes['GET /v1/billing/entitlements'] = (_) => _json({'error': 'down'}, 500); + await h.pump(tester, const EntitlementGate(feature: 'sso', fallback: Text('upgrade'), child: Text('sso settings'))); + expect(find.text('sso settings'), findsNothing); + expect(find.text('upgrade'), findsOneWidget); + }); + + testWidgets('PricingTable lists plans and changes plan', (tester) async { + final h = _Harness(); + var patched = 0; + h.routes['PATCH /v1/billing/subscription'] = (_) { + patched++; + return _json({'subscription': subJson({'plan': planJson('team', {'amount_minor': 9900})})}); + }; + await h.pump(tester, const PricingTable()); + expect(find.text(r'$99.00'), findsOneWidget); + expect(find.text('No charge'), findsOneWidget); + await tester.tap(find.text('Choose Team')); + await _settle(tester); + expect(patched, 1); + expect(find.text('Choose Pro'), findsOneWidget); + }); + + testWidgets('PricingTable hands a checkout URL to onCheckout', (tester) async { + final h = _Harness(); + h.routes['GET /v1/billing/subscription'] = (_) => _json({'subscription': null, 'can_manage': true}); + h.routes['POST /v1/billing/subscription'] = (_) => _json({'checkout_url': 'https://pay.example/1'}); + Uri? opened; + await h.pump(tester, PricingTable(onCheckout: (uri) => opened = uri)); + await tester.tap(find.text('Choose Pro')); + await _settle(tester); + expect(opened, Uri.parse('https://pay.example/1')); + }); + + testWidgets('PricingTable shows plain words, not the server message, when a change fails', (tester) async { + final h = _Harness(); + h.routes['PATCH /v1/billing/subscription'] = + (_) => _json({'error': 'already subscribed: use PATCH to change plan'}, 409); + Object? reported; + await h.pump(tester, PricingTable(onError: (e) => reported = e)); + await tester.tap(find.text('Choose Team')); + await _settle(tester); + expect(find.text("Couldn't change your plan. Try again."), findsOneWidget); + expect(find.textContaining('already subscribed'), findsNothing); + expect(reported, isA()); + }); + + testWidgets('PricingTable reports a checkout it has nowhere to send', (tester) async { + final h = _Harness(); + h.routes['GET /v1/billing/subscription'] = (_) => _json({'subscription': null, 'can_manage': true}); + h.routes['POST /v1/billing/subscription'] = (_) => _json({'checkout_url': 'https://pay.example/1'}); + Object? reported; + await h.pump(tester, PricingTable(onError: (e) => reported = e)); + await tester.tap(find.text('Choose Pro')); + await _settle(tester); + expect(find.text("Couldn't open the payment page."), findsOneWidget); + expect(reported, isA()); + expect('$reported', contains('onCheckout')); + }); + + testWidgets('PricingTable says which plan is highlighted, not only with colour', (tester) async { + await _Harness().pump(tester, const PricingTable(highlight: 'team')); + expect(find.text('Recommended'), findsOneWidget); + }); + + testWidgets('widgets stop loading when nobody is signed in', (tester) async { + await _Harness().pump( + tester, + const Column(children: [ + EntitlementGate(feature: 'sso', fallback: Text('locked'), loading: Text('checking'), child: Text('open')), + CurrentPlanCard(), + PricingTable(), + UsageMeter(feature: 'sso'), + ]), + signedOut: true, + ); + expect(find.text('locked'), findsOneWidget); + expect(find.text('checking'), findsNothing); + expect(find.byType(CircularProgressIndicator), findsNothing); + expect(find.byType(LinearProgressIndicator), findsNothing); + expect(find.text('No plan yet'), findsOneWidget); + }); + + testWidgets('CurrentPlanCard says when a canceled plan ends and keeps it', (tester) async { + final h = _Harness(); + h.routes['GET /v1/billing/subscription'] = (_) => _json({ + 'subscription': subJson({'cancel_at': '2026-12-01T12:00:00Z'}), + 'can_manage': true, + }); + var resumed = 0; + h.routes['POST /v1/billing/subscription/resume'] = (_) { + resumed++; + return _json({'subscription': subJson()}); + }; + await h.pump(tester, const CurrentPlanCard()); + expect(find.textContaining('Ends on Dec 1, 2026'), findsOneWidget); + await tester.tap(find.text('Keep plan')); + await _settle(tester); + expect(resumed, 1); + expect(find.textContaining('Renews on'), findsOneWidget); + }); + + testWidgets('CurrentPlanCard shows plain words when cancel fails', (tester) async { + final h = _Harness(); + h.routes['POST /v1/billing/subscription/cancel'] = (_) => _json({'error': 'ledger: row locked'}, 500); + Object? reported; + await h.pump(tester, CurrentPlanCard(onError: (e) => reported = e)); + await tester.tap(find.text('Cancel plan')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Yes, cancel')); + await _settle(tester); + expect(find.text("Couldn't update your plan. Try again."), findsOneWidget); + expect(reported, isNotNull); + }); + + testWidgets('CurrentPlanCard asks before canceling, and cancels only on yes', (tester) async { + final h = _Harness(); + var canceled = 0; + h.routes['POST /v1/billing/subscription/cancel'] = (_) { + canceled++; + return _json({'subscription': subJson({'cancel_at': '2026-12-01T12:00:00Z'})}); + }; + await h.pump(tester, const CurrentPlanCard()); + await tester.tap(find.text('Cancel plan')); + await tester.pumpAndSettle(); + expect(find.text('Cancel your plan?'), findsOneWidget); + expect(find.textContaining('You keep it until'), findsOneWidget); + expect(canceled, 0); + + await tester.tap(find.text('Never mind')); + await tester.pumpAndSettle(); + expect(find.text('Cancel your plan?'), findsNothing); + expect(canceled, 0); + + await tester.tap(find.text('Cancel plan')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Yes, cancel')); + await _settle(tester); + expect(canceled, 1); + expect(find.text('Keep plan'), findsOneWidget); + }); + + testWidgets('CurrentPlanCard shows and changes another tenant when given a tenantId', (tester) async { + final h = _Harness(); + Object? body; + h.routes['POST /v1/billing/subscription/cancel'] = (req) { + body = jsonDecode(req.body); + return _json({'subscription': subJson({'cancel_at': '2026-12-01T12:00:00Z'})}); + }; + await h.pump(tester, const Column(children: [ + CurrentPlanCard(tenantId: 'org_9'), + EntitlementGate(tenantId: 'org_9', feature: 'sso', fallback: Text('locked'), child: Text('open')), + ])); + expect(h.urls, contains('GET /v1/billing/subscription?tenant_id=org_9')); + expect(h.urls, isNot(contains('GET /v1/billing/subscription'))); + expect(find.text('open'), findsOneWidget); + await tester.tap(find.text('Cancel plan')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Yes, cancel')); + await _settle(tester); + expect(body, {'when': 'period_end', 'tenant_id': 'org_9'}); + }); + + testWidgets('CurrentPlanCard shows seats and no buttons to a member', (tester) async { + final h = _Harness(); + h.routes['GET /v1/billing/subscription'] = (_) => _json({ + 'subscription': subJson({'seats': {'feature_key': 'seats', 'used': 3, 'limit': 10}}), + 'can_manage': false, + }); + await h.pump(tester, const CurrentPlanCard()); + expect(find.text('3 of 10 seats'), findsOneWidget); + expect(find.byType(FilledButton), findsNothing); + expect(find.byType(OutlinedButton), findsNothing); + }); + + testWidgets('UsageMeter shows use against the limit', (tester) async { + await _Harness().pump(tester, const UsageMeter(feature: 'sso', label: 'SSO connections')); + expect(find.text('2 of 10'), findsOneWidget); + expect(find.text('SSO connections'), findsOneWidget); + }); + + testWidgets('ManageBillingButton opens the portal', (tester) async { + final h = _Harness(); + h.routes['POST /v1/billing/portal'] = (_) => _json({'url': 'https://pay.example/portal'}); + Uri? opened; + await h.pump(tester, ManageBillingButton(onOpen: (uri) => opened = uri)); + await tester.tap(find.text('Manage billing')); + await _settle(tester); + expect(opened, Uri.parse('https://pay.example/portal')); + }); +} diff --git a/flutter/pubspec.yaml b/flutter/pubspec.yaml index 61e8698a..7e11c813 100644 --- a/flutter/pubspec.yaml +++ b/flutter/pubspec.yaml @@ -8,4 +8,6 @@ workspace: - packages/authsome_core - packages/authsome_flutter - packages/authsome_flutter_ui + - packages/authsome_subscription + - packages/authsome_subscription_flutter - example diff --git a/scripts/bump-flutter-version.sh b/scripts/bump-flutter-version.sh index 502800d5..aaf52bd6 100755 --- a/scripts/bump-flutter-version.sh +++ b/scripts/bump-flutter-version.sh @@ -18,7 +18,7 @@ if ! printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+([-+].+)?$'; then fi PKG_DIR="$(cd "$(dirname "$0")/../flutter/packages" && pwd)" -PACKAGES=(authsome_core authsome_flutter authsome_flutter_ui) +PACKAGES=(authsome_core authsome_flutter authsome_flutter_ui authsome_subscription authsome_subscription_flutter) echo "Bumping Flutter packages to $VERSION..." @@ -29,11 +29,13 @@ for pkg in "${PACKAGES[@]}"; do echo " $pkg -> $VERSION" done -# Inter-package constraints track the release version: the three are published as +# Inter-package constraints track the release version: the packages are published as # a set, so a dependent never wants an older sibling. Anchored on leading # whitespace so the `name:` key at column 0 can't match. perl -pi -e "s{^(\s+authsome_core:).*}{\$1 ^$VERSION}" "$PKG_DIR/authsome_flutter/pubspec.yaml" perl -pi -e "s{^(\s+authsome_flutter:).*}{\$1 ^$VERSION}" "$PKG_DIR/authsome_flutter_ui/pubspec.yaml" +perl -pi -e "s{^(\s+authsome_core:).*}{\$1 ^$VERSION}" "$PKG_DIR/authsome_subscription/pubspec.yaml" +perl -pi -e "s{^(\s+authsome_(flutter|subscription):).*}{\$1 ^$VERSION}" "$PKG_DIR/authsome_subscription_flutter/pubspec.yaml" echo " inter-package constraints -> ^$VERSION" echo diff --git a/scripts/prepare-npm-publish.sh b/scripts/prepare-npm-publish.sh index 0ee7f28b..a54a2188 100755 --- a/scripts/prepare-npm-publish.sh +++ b/scripts/prepare-npm-publish.sh @@ -12,7 +12,7 @@ echo "Preparing npm packages for publish at version $VERSION..." # Replace workspace:* with ^VERSION first — npm itself doesn't understand the # workspace: protocol, so any npm invocation (including `npm version`) on a # package.json containing workspace:* fails with EUNSUPPORTEDPROTOCOL. -for pkg in ui/packages/react ui/packages/vue ui/packages/components ui/packages/nextjs; do +for pkg in ui/packages/react ui/packages/vue ui/packages/components ui/packages/nextjs ui/packages/plugin-subscription; do cd "$ROOT/$pkg" sed -i "s/\"workspace:\*\"/\"^$VERSION\"/g" package.json echo " Resolved workspace:* references in $pkg" @@ -26,7 +26,7 @@ done # including apps/storybook, whose package.json still uses the workspace: # protocol and trips EUNSUPPORTEDPROTOCOL. Edit package.json directly with # node to bypass that machinery; node is always present when npm is. -for pkg in ui/packages/core ui/packages/react ui/packages/vue ui/packages/components ui/packages/nextjs sdk/typescript; do +for pkg in ui/packages/core ui/packages/react ui/packages/vue ui/packages/components ui/packages/nextjs ui/packages/plugin-subscription sdk/typescript; do cd "$ROOT/$pkg" node -e "const fs=require('fs'); const p=JSON.parse(fs.readFileSync('package.json','utf8')); p.version='$VERSION'; fs.writeFileSync('package.json', JSON.stringify(p, null, 2) + '\n');" echo " Set $pkg version to $VERSION" diff --git a/sdkgen/Makefile b/sdkgen/Makefile index 3e03c8ff..d5937f10 100644 --- a/sdkgen/Makefile +++ b/sdkgen/Makefile @@ -27,6 +27,15 @@ SPEC_FILE ?= spec.json UI_CORE_OVERRIDES = refreshTokens=refresh,deleteAccount=deleteMe,exportUserData=exportMyData,verifyMFARecovery=verifyRecoveryCode FLUTTER_CORE_OVERRIDES = refreshTokens=refresh,deleteAccount=deleteMe,exportUserData=exportMyData,verifyMFARecovery=verifyRecoveryCode +# The embedded core clients point billing callers at the subscription plugin +# packages, which own the tenant routes (tagged "Billing") now. The operator +# routes ("Billing Plans", "Billing Usage", ...) belong on a server, so the +# longer "Billing " rule says that instead. The standalone SDKs keep all of +# them undeprecated. Rules are separated by semicolons; write a semicolon +# inside a note as \; (sdkgen escapes everything else for each language). +UI_CORE_DEPRECATIONS = Billing=Use @authsome/plugin-subscription instead.;Billing =Operator route. Call it from your server with @authsome/client, or use @authsome/plugin-subscription for tenant billing. +FLUTTER_CORE_DEPRECATIONS = Billing=Use the authsome_subscription package instead.;Billing =Operator route. Call it from your server with the authsome Dart SDK, or use authsome_subscription for tenant billing. + SDKGEN_CMD = go run ./cmd SPECGEN_CMD = go run ./cmd/specgen @@ -66,13 +75,15 @@ generate-dart: dump-spec ## Generate Dart SDK only generate-ui-core: dump-spec ## Generate embedded client for ui-core package @echo "==> Generating embedded TypeScript client for ui-core..." $(SDKGEN_CMD) generate --lang=typescript --out=../ui/packages/core/src/generated \ - --from-spec=$(SPEC_FILE) --output-mode=embedded --method-overrides=$(UI_CORE_OVERRIDES) $(SDKGEN_FLAGS) + --from-spec=$(SPEC_FILE) --output-mode=embedded --method-overrides=$(UI_CORE_OVERRIDES) \ + --deprecate-tags="$(UI_CORE_DEPRECATIONS)" $(SDKGEN_FLAGS) @echo "==> Embedded client generated in ../ui/packages/core/src/generated/" generate-flutter-core: dump-spec ## Generate embedded client for Flutter authsome_core package @echo "==> Generating embedded Dart client for Flutter authsome_core..." $(SDKGEN_CMD) generate --lang=dart --out=../flutter/packages/authsome_core/lib/src/generated \ - --from-spec=$(SPEC_FILE) --output-mode=embedded --method-overrides=$(FLUTTER_CORE_OVERRIDES) $(SDKGEN_FLAGS) + --from-spec=$(SPEC_FILE) --output-mode=embedded --method-overrides=$(FLUTTER_CORE_OVERRIDES) \ + --deprecate-tags="$(FLUTTER_CORE_DEPRECATIONS)" $(SDKGEN_FLAGS) @echo "==> Embedded Dart client generated in ../flutter/packages/authsome_core/lib/src/generated/" clean: ## Remove generated SDK output and spec file diff --git a/sdkgen/cmd/main.go b/sdkgen/cmd/main.go index 66b2b408..d3405c95 100644 --- a/sdkgen/cmd/main.go +++ b/sdkgen/cmd/main.go @@ -63,6 +63,7 @@ func main() { outputMode := fs.String("output-mode", "standalone", "Output mode: standalone or embedded") methodOverrides := fs.String("method-overrides", "", "Comma-separated operationID=name overrides") fromSpec := fs.String("from-spec", "", "Path to pre-generated OpenAPI spec JSON (skip internal generation)") + deprecateTags := fs.String("deprecate-tags", "", "Semicolon-separated tagPrefix=note rules marking operations deprecated (e.g. \"Billing=Use @authsome/plugin-subscription instead.\"); the longest matching prefix wins, and \\; puts a semicolon in a note") if err := fs.Parse(os.Args[2:]); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) @@ -89,6 +90,8 @@ func main() { } } + deprecated := openapi.ParseDeprecationRules(*deprecateTags) + // Load or generate OpenAPI spec. var spec *openapi.Spec if *fromSpec != "" { @@ -113,7 +116,7 @@ func main() { // Generate SDKs switch strings.ToLower(*lang) { case "typescript", "ts": - if err := generateTypeScript(spec, *outDir, *outputMode, overrides); err != nil { + if err := generateTypeScript(spec, *outDir, *outputMode, overrides, deprecated); err != nil { fmt.Fprintf(os.Stderr, "Error generating TypeScript SDK: %v\n", err) os.Exit(1) } @@ -123,12 +126,12 @@ func main() { os.Exit(1) } case "dart", "flutter": - if err := generateDart(spec, *outDir, *outputMode, overrides); err != nil { + if err := generateDart(spec, *outDir, *outputMode, overrides, deprecated); err != nil { fmt.Fprintf(os.Stderr, "Error generating Dart SDK: %v\n", err) os.Exit(1) } case "all": - if err := generateTypeScript(spec, filepath.Join(*outDir, "typescript"), *outputMode, overrides); err != nil { + if err := generateTypeScript(spec, filepath.Join(*outDir, "typescript"), *outputMode, overrides, deprecated); err != nil { fmt.Fprintf(os.Stderr, "Error generating TypeScript SDK: %v\n", err) os.Exit(1) } @@ -136,7 +139,7 @@ func main() { fmt.Fprintf(os.Stderr, "Error generating Go SDK: %v\n", err) os.Exit(1) } - if err := generateDart(spec, filepath.Join(*outDir, "dart"), *outputMode, overrides); err != nil { + if err := generateDart(spec, filepath.Join(*outDir, "dart"), *outputMode, overrides, deprecated); err != nil { fmt.Fprintf(os.Stderr, "Error generating Dart SDK: %v\n", err) os.Exit(1) } @@ -148,12 +151,13 @@ func main() { fmt.Println("Done!") } -func generateTypeScript(spec *openapi.Spec, outDir, outputMode string, overrides map[string]string) error { +func generateTypeScript(spec *openapi.Spec, outDir, outputMode string, overrides, deprecated map[string]string) error { fmt.Printf("Generating TypeScript SDK -> %s (mode: %s)\n", outDir, outputMode) gen := typescript.NewGenerator(typescript.GeneratorConfig{ OutputMode: outputMode, MethodOverrides: overrides, + DeprecatedTags: deprecated, }) files, err := gen.Generate(spec) if err != nil { @@ -163,12 +167,13 @@ func generateTypeScript(spec *openapi.Spec, outDir, outputMode string, overrides return writeFiles(outDir, files) } -func generateDart(spec *openapi.Spec, outDir, outputMode string, overrides map[string]string) error { +func generateDart(spec *openapi.Spec, outDir, outputMode string, overrides, deprecated map[string]string) error { fmt.Printf("Generating Dart SDK -> %s (mode: %s)\n", outDir, outputMode) gen := dart.NewGenerator(dart.GeneratorConfig{ OutputMode: outputMode, MethodOverrides: overrides, + DeprecatedTags: deprecated, }) files, err := gen.Generate(spec) if err != nil { diff --git a/sdkgen/dart/deprecate_test.go b/sdkgen/dart/deprecate_test.go new file mode 100644 index 00000000..d43ba070 --- /dev/null +++ b/sdkgen/dart/deprecate_test.go @@ -0,0 +1,69 @@ +package dart_test + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/xraph/authsome/sdkgen/dart" + "github.com/xraph/authsome/sdkgen/openapi" +) + +// Operations whose tag starts with a deprecated prefix get @Deprecated; +// everything else is left alone. +func TestGenerate_DeprecatesTaggedOperations(t *testing.T) { + spec, err := openapi.LoadSpecFromFile("../spec.json") + require.NoError(t, err) + + gen := dart.NewGenerator(dart.GeneratorConfig{ + OutputMode: "embedded", + DeprecatedTags: map[string]string{ + "Billing": "Use the authsome_subscription package instead.", + "Billing ": "Operator route. Call it from your server.", + }, + }) + files, err := gen.Generate(spec) + require.NoError(t, err) + + var client string + for _, f := range files { + if strings.HasSuffix(f.Path, "api_client.dart") { + client = f.Content + } + } + require.NotEmpty(t, client) + + before := func(method string) string { + i := strings.Index(client, "> "+method+"(") + require.Greater(t, i, 0, method) + start := strings.LastIndex(client[:i], "\n\n") + return client[start:i] + } + assert.Contains(t, before("getCurrentSubscription"), "@Deprecated('Use the authsome_subscription package instead.')") + assert.Contains(t, before("getActiveSubscription"), "@Deprecated('Operator route. Call it from your server.')") + assert.NotContains(t, before("getMe"), "@Deprecated") +} + +// A note is a Dart string literal, so quotes, dollars and backslashes are +// escaped and it can't end the annotation early. +func TestGenerate_EscapesDeprecationNotes(t *testing.T) { + spec, err := openapi.LoadSpecFromFile("../spec.json") + require.NoError(t, err) + + gen := dart.NewGenerator(dart.GeneratorConfig{ + OutputMode: "embedded", + DeprecatedTags: map[string]string{"Billing": `Don't use $this \ here`}, + }) + files, err := gen.Generate(spec) + require.NoError(t, err) + + for _, f := range files { + if strings.HasSuffix(f.Path, "api_client.dart") { + assert.Contains(t, f.Content, `@Deprecated('Don\'t use \$this \\ here')`) + return + } + } + t.Fatal("no api_client.dart") +} diff --git a/sdkgen/dart/escape.go b/sdkgen/dart/escape.go new file mode 100644 index 00000000..3fd04d29 --- /dev/null +++ b/sdkgen/dart/escape.go @@ -0,0 +1,15 @@ +package dart + +import "strings" + +// dartStringBody makes s safe between the single quotes of a Dart string +// literal: backslashes, quotes and dollars (interpolation) are escaped, and +// line breaks become spaces since the literal is single-line. +var dartStringBody = strings.NewReplacer( + `\`, `\\`, + `'`, `\'`, + `$`, `\$`, + "\r\n", " ", + "\n", " ", + "\r", " ", +).Replace diff --git a/sdkgen/dart/generator.go b/sdkgen/dart/generator.go index fb178917..d4241025 100644 --- a/sdkgen/dart/generator.go +++ b/sdkgen/dart/generator.go @@ -31,6 +31,12 @@ type GeneratorConfig struct { // MethodOverrides maps operationID to a custom method name. MethodOverrides map[string]string + + // DeprecatedTags marks every operation whose tag starts with a key as + // deprecated, with the value as the note. Used to point the embedded core + // client at the plugin package that now owns those routes. The note must + // not contain a single quote. + DeprecatedTags map[string]string } // Generator produces Dart SDK code from an OpenAPI spec. @@ -158,6 +164,9 @@ type OperationDef struct { AuthRequired bool PathParams []PathParamDef QueryParams []QueryParamDef + + // Deprecated is the deprecation note, empty when the operation isn't. + Deprecated string } // ── Build Template Data ───────────────────────────── @@ -239,6 +248,7 @@ func (g *Generator) buildTemplateData(spec *openapi.Spec) *TemplateData { Path: path, Summary: pair.op.Summary, } + opDef.Deprecated = dartStringBody(openapi.DeprecationFor(pair.op.Tags, g.config.DeprecatedTags)) // Determine if auth is required opDef.AuthRequired = openapi.OperationTakesToken(pair.op, spec.SecuritySchemes()) diff --git a/sdkgen/dart/templates/client.dart.tmpl b/sdkgen/dart/templates/client.dart.tmpl index c309ee0e..0205032f 100644 --- a/sdkgen/dart/templates/client.dart.tmpl +++ b/sdkgen/dart/templates/client.dart.tmpl @@ -71,6 +71,9 @@ class AuthClient { {{ range $op := .Operations }} /// {{ $op.Summary }} /// {{ $op.Method }} {{ $op.Path }} +{{- if $op.Deprecated }} + @Deprecated('{{ $op.Deprecated }}') +{{- end }} Future<{{ if isVoid $op.ResponseType }}void{{ else }}{{ $op.ResponseType }}{{ end }}> {{ $op.Name }}({{ buildDartParams $op }}) async { final path = {{ buildDartPath $op.Path $op.PathParams }}; {{- if hasQueryParams $op.QueryParams }} diff --git a/sdkgen/openapi/deprecate.go b/sdkgen/openapi/deprecate.go new file mode 100644 index 00000000..df7745dd --- /dev/null +++ b/sdkgen/openapi/deprecate.go @@ -0,0 +1,62 @@ +package openapi + +import "strings" + +// ParseDeprecationRules reads the --deprecate-tags flag: tagPrefix=note +// rules separated by semicolons. A note can contain a semicolon written as +// \;. Rules without an "=" or with an empty prefix are skipped. Only the +// whole rule is trimmed, so "Billing =note" keeps the prefix "Billing ". +func ParseDeprecationRules(flag string) map[string]string { + rules := make(map[string]string) + var rule strings.Builder + flush := func() { + parts := strings.SplitN(strings.TrimSpace(rule.String()), "=", 2) + if len(parts) == 2 && parts[0] != "" { + rules[parts[0]] = strings.TrimSpace(parts[1]) + } + rule.Reset() + } + for i := 0; i < len(flag); i++ { + switch { + case flag[i] == '\\' && i+1 < len(flag) && flag[i+1] == ';': + rule.WriteByte(';') + i++ + case flag[i] == ';': + flush() + default: + rule.WriteByte(flag[i]) + } + } + flush() + return rules +} + +// DeprecationFor returns the note of the rule whose tag prefix starts one of +// tags, or "" when none does. Rules map a tag prefix (e.g. "Billing") to the +// note shown to callers. When several match, the longest prefix wins, so +// "Billing " (the operator groups such as "Billing Plans") can carry a +// different note from "Billing"; ties go to the alphabetically first prefix +// so the output is stable. +func DeprecationFor(tags []string, rules map[string]string) string { + best := "" + found := false + for p := range rules { + matches := false + for _, tag := range tags { + if strings.HasPrefix(tag, p) { + matches = true + break + } + } + if !matches { + continue + } + if !found || len(p) > len(best) || (len(p) == len(best) && p < best) { + best, found = p, true + } + } + if !found { + return "" + } + return rules[best] +} diff --git a/sdkgen/openapi/deprecate_test.go b/sdkgen/openapi/deprecate_test.go new file mode 100644 index 00000000..5c5a4c7d --- /dev/null +++ b/sdkgen/openapi/deprecate_test.go @@ -0,0 +1,35 @@ +package openapi_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/xraph/authsome/sdkgen/openapi" +) + +// The tenant routes are tagged "Billing" and the operator ones "Billing +// Plans", "Billing Usage" and so on, so the longer "Billing " rule has to win +// over "Billing" whatever order the map gives them in. +func TestDeprecationFor_LongestPrefixWins(t *testing.T) { + rules := map[string]string{ + "Billing": "tenant", + "Billing ": "operator", + } + assert.Equal(t, "tenant", openapi.DeprecationFor([]string{"Billing"}, rules)) + assert.Equal(t, "operator", openapi.DeprecationFor([]string{"Billing Plans"}, rules)) + assert.Equal(t, "operator", openapi.DeprecationFor([]string{"Auth", "Billing Usage"}, rules)) + assert.Empty(t, openapi.DeprecationFor([]string{"Auth"}, rules)) + assert.Empty(t, openapi.DeprecationFor([]string{"Billing"}, nil)) +} + +// Rules come from one flag, split on semicolons; a note can still contain +// one when it's escaped as \;. +func TestParseDeprecationRules(t *testing.T) { + assert.Equal(t, map[string]string{ + "Billing": "Use the plugin; it's newer.", + "Billing ": "Operator route.", + }, openapi.ParseDeprecationRules(`Billing=Use the plugin\; it's newer.;Billing =Operator route.`)) + assert.Empty(t, openapi.ParseDeprecationRules("")) + assert.Empty(t, openapi.ParseDeprecationRules("no-equals; =no key")) +} diff --git a/sdkgen/typescript/deprecate_test.go b/sdkgen/typescript/deprecate_test.go new file mode 100644 index 00000000..8e938945 --- /dev/null +++ b/sdkgen/typescript/deprecate_test.go @@ -0,0 +1,70 @@ +package typescript_test + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/xraph/authsome/sdkgen/openapi" + "github.com/xraph/authsome/sdkgen/typescript" +) + +// Operations whose tag starts with a deprecated prefix get an @deprecated +// tag in their doc comment; everything else is left alone. +func TestGenerate_DeprecatesTaggedOperations(t *testing.T) { + spec, err := openapi.LoadSpecFromFile("../spec.json") + require.NoError(t, err) + + gen := typescript.NewGenerator(typescript.GeneratorConfig{ + OutputMode: "embedded", + DeprecatedTags: map[string]string{ + "Billing": "Use @authsome/plugin-subscription instead.", + "Billing ": "Operator route. Call it from your server with @authsome/client.", + }, + }) + files, err := gen.Generate(spec) + require.NoError(t, err) + + var client string + for _, f := range files { + if strings.HasSuffix(f.Path, "api-client.ts") { + client = f.Content + } + } + require.NotEmpty(t, client) + + docFor := func(method string) string { + i := strings.Index(client, " async "+method+"(") + require.Greater(t, i, 0, method) + start := strings.LastIndex(client[:i], "/**") + return client[start:i] + } + assert.Contains(t, docFor("getCurrentSubscription"), "@deprecated Use @authsome/plugin-subscription instead.") + assert.Contains(t, docFor("getActiveSubscription"), "@deprecated Operator route. Call it from your server with @authsome/client.") + assert.Contains(t, docFor("subscribe"), "@deprecated") + assert.NotContains(t, docFor("getMe"), "@deprecated") +} + +// A note sits inside a JSDoc block, so "*/" can't close it early. +func TestGenerate_EscapesDeprecationNotes(t *testing.T) { + spec, err := openapi.LoadSpecFromFile("../spec.json") + require.NoError(t, err) + + gen := typescript.NewGenerator(typescript.GeneratorConfig{ + OutputMode: "embedded", + DeprecatedTags: map[string]string{"Billing": "Read docs/*/billing */ first"}, + }) + files, err := gen.Generate(spec) + require.NoError(t, err) + + for _, f := range files { + if strings.HasSuffix(f.Path, "api-client.ts") { + assert.Contains(t, f.Content, `@deprecated Read docs/*\/billing *\/ first`) + assert.NotContains(t, f.Content, "billing */ first") + return + } + } + t.Fatal("no api-client.ts") +} diff --git a/sdkgen/typescript/escape.go b/sdkgen/typescript/escape.go new file mode 100644 index 00000000..091935d3 --- /dev/null +++ b/sdkgen/typescript/escape.go @@ -0,0 +1,12 @@ +package typescript + +import "strings" + +// jsDocText makes s safe inside a /** ... */ block: "*/" would close it, and +// a line break would start a line without the leading " * ". +var jsDocText = strings.NewReplacer( + "*/", `*\/`, + "\r\n", " ", + "\n", " ", + "\r", " ", +).Replace diff --git a/sdkgen/typescript/generator.go b/sdkgen/typescript/generator.go index 09af7382..20f5e59f 100644 --- a/sdkgen/typescript/generator.go +++ b/sdkgen/typescript/generator.go @@ -32,6 +32,11 @@ type GeneratorConfig struct { // MethodOverrides maps operationID to a custom method name. // e.g., {"refreshSession": "refresh", "deleteAccount": "deleteMe"} MethodOverrides map[string]string + + // DeprecatedTags marks every operation whose tag starts with a key as + // deprecated, with the value as the note. Used to point the embedded core + // clients at the plugin package that now owns those routes. + DeprecatedTags map[string]string } // Generator produces TypeScript SDK code from an OpenAPI spec. @@ -186,6 +191,9 @@ type OperationDef struct { PathParams []PathParamDef QueryParams []QueryParamDef NeedsRequestAlias bool // true when the request type alias differs from an existing schema type + + // Deprecated is the deprecation note, empty when the operation isn't. + Deprecated string } func (g *Generator) buildTemplateData(spec *openapi.Spec) *TemplateData { @@ -271,6 +279,7 @@ func (g *Generator) buildTemplateData(spec *openapi.Spec) *TemplateData { Summary: pair.op.Summary, Tags: pair.op.Tags, } + opDef.Deprecated = jsDocText(openapi.DeprecationFor(pair.op.Tags, g.config.DeprecatedTags)) // Determine if auth is required opDef.AuthRequired = openapi.OperationTakesToken(pair.op, spec.SecuritySchemes()) diff --git a/sdkgen/typescript/templates/client.ts.tmpl b/sdkgen/typescript/templates/client.ts.tmpl index 9ccf8294..6c3f98fe 100644 --- a/sdkgen/typescript/templates/client.ts.tmpl +++ b/sdkgen/typescript/templates/client.ts.tmpl @@ -161,6 +161,9 @@ export class AuthClient { /** * {{ $op.Summary }} * {{ $op.Method }} {{ $op.Path }} +{{- if $op.Deprecated }} + * @deprecated {{ $op.Deprecated }} +{{- end }} */ async {{ $op.Name }}({{ buildParams $op $.OutputMode }}): Promise<{{ $op.ResponseType }}> { {{- if hasQueryParams $op.QueryParams }} diff --git a/ui/packages/core/src/generated/api-client.ts b/ui/packages/core/src/generated/api-client.ts index 03de1bf7..1c1b7884 100644 --- a/ui/packages/core/src/generated/api-client.ts +++ b/ui/packages/core/src/generated/api-client.ts @@ -1457,6 +1457,7 @@ export class AuthClient { /** * List coupons * GET /v1/billing/coupons + * @deprecated Operator route. Call it from your server with @authsome/client, or use @authsome/plugin-subscription for tenant billing. */ async listCoupons(app_id: string, token: string): Promise { const params = new URLSearchParams(); @@ -1474,6 +1475,7 @@ export class AuthClient { /** * Create coupon * POST /v1/billing/coupons + * @deprecated Operator route. Call it from your server with @authsome/client, or use @authsome/plugin-subscription for tenant billing. */ async createCoupon(body: CreateCouponRequest, token: string): Promise { const path = "/v1/billing/coupons"; @@ -1488,6 +1490,7 @@ export class AuthClient { /** * Delete coupon * DELETE /v1/billing/coupons/{couponId} + * @deprecated Operator route. Call it from your server with @authsome/client, or use @authsome/plugin-subscription for tenant billing. */ async deleteCoupon(couponId: string, token: string): Promise { const path = `/v1/billing/coupons/${couponId}`; @@ -1502,6 +1505,7 @@ export class AuthClient { /** * List your entitlements * GET /v1/billing/entitlements + * @deprecated Use @authsome/plugin-subscription instead. */ async listEntitlements(token: string, tenant_id?: string): Promise { const params = new URLSearchParams(); @@ -1519,6 +1523,7 @@ export class AuthClient { /** * Check feature entitlement * GET /v1/billing/entitlements/{featureKey} + * @deprecated Operator route. Call it from your server with @authsome/client, or use @authsome/plugin-subscription for tenant billing. */ async checkEntitlement(featureKey: string, tenant_id: string, token: string, app_id?: string): Promise { const params = new URLSearchParams(); @@ -1537,6 +1542,7 @@ export class AuthClient { /** * List invoices * GET /v1/billing/invoices + * @deprecated Operator route. Call it from your server with @authsome/client, or use @authsome/plugin-subscription for tenant billing. */ async listInvoices(app_id: string, tenant_id: string, token: string): Promise { const params = new URLSearchParams(); @@ -1555,6 +1561,7 @@ export class AuthClient { /** * Get invoice * GET /v1/billing/invoices/{invoiceId} + * @deprecated Operator route. Call it from your server with @authsome/client, or use @authsome/plugin-subscription for tenant billing. */ async getInvoice(invoiceId: string, token: string): Promise { const path = `/v1/billing/invoices/${invoiceId}`; @@ -1569,6 +1576,7 @@ export class AuthClient { /** * Mark invoice as paid * POST /v1/billing/invoices/{invoiceId}/pay + * @deprecated Operator route. Call it from your server with @authsome/client, or use @authsome/plugin-subscription for tenant billing. */ async markInvoicePaid(invoiceId: string, body: MarkInvoicePaidRequest, token: string): Promise { const path = `/v1/billing/invoices/${invoiceId}/pay`; @@ -1583,6 +1591,7 @@ export class AuthClient { /** * Void an invoice * POST /v1/billing/invoices/{invoiceId}/void + * @deprecated Operator route. Call it from your server with @authsome/client, or use @authsome/plugin-subscription for tenant billing. */ async voidInvoice(invoiceId: string, body: VoidInvoiceRequest, token: string): Promise { const path = `/v1/billing/invoices/${invoiceId}/void`; @@ -1597,6 +1606,7 @@ export class AuthClient { /** * List billing plans * GET /v1/billing/plans + * @deprecated Operator route. Call it from your server with @authsome/client, or use @authsome/plugin-subscription for tenant billing. */ async listBillingPlans(app_id: string, token: string, status?: string, include_addons?: boolean): Promise { const params = new URLSearchParams(); @@ -1616,6 +1626,7 @@ export class AuthClient { /** * Create billing plan * POST /v1/billing/plans + * @deprecated Operator route. Call it from your server with @authsome/client, or use @authsome/plugin-subscription for tenant billing. */ async createBillingPlan(body: CreateBillingPlanRequest, token: string): Promise { const path = "/v1/billing/plans"; @@ -1630,6 +1641,7 @@ export class AuthClient { /** * Get billing plan * GET /v1/billing/plans/{planId} + * @deprecated Operator route. Call it from your server with @authsome/client, or use @authsome/plugin-subscription for tenant billing. */ async getBillingPlan(planId: string, token: string): Promise { const path = `/v1/billing/plans/${planId}`; @@ -1644,6 +1656,7 @@ export class AuthClient { /** * Activate a billing plan * POST /v1/billing/plans/{planId}/activate + * @deprecated Operator route. Call it from your server with @authsome/client, or use @authsome/plugin-subscription for tenant billing. */ async activateBillingPlan(planId: string, token: string): Promise { const path = `/v1/billing/plans/${planId}/activate`; @@ -1658,6 +1671,7 @@ export class AuthClient { /** * Archive a billing plan * POST /v1/billing/plans/{planId}/archive + * @deprecated Operator route. Call it from your server with @authsome/client, or use @authsome/plugin-subscription for tenant billing. */ async archiveBillingPlan(planId: string, token: string): Promise { const path = `/v1/billing/plans/${planId}/archive`; @@ -1672,6 +1686,7 @@ export class AuthClient { /** * Open the billing portal * POST /v1/billing/portal + * @deprecated Use @authsome/plugin-subscription instead. */ async createBillingPortalSession(body: CreateBillingPortalSessionRequest, token: string): Promise { const path = "/v1/billing/portal"; @@ -1686,6 +1701,7 @@ export class AuthClient { /** * Get your subscription * GET /v1/billing/subscription + * @deprecated Use @authsome/plugin-subscription instead. */ async getCurrentSubscription(token: string, tenant_id?: string): Promise { const params = new URLSearchParams(); @@ -1703,6 +1719,7 @@ export class AuthClient { /** * Subscribe to a plan * POST /v1/billing/subscription + * @deprecated Use @authsome/plugin-subscription instead. */ async subscribe(body: SubscribeRequest, token: string): Promise { const path = "/v1/billing/subscription"; @@ -1717,6 +1734,7 @@ export class AuthClient { /** * Change plan or seats * PATCH /v1/billing/subscription + * @deprecated Use @authsome/plugin-subscription instead. */ async updateSubscription(body: UpdateSubscriptionRequest, token: string): Promise { const path = "/v1/billing/subscription"; @@ -1731,6 +1749,7 @@ export class AuthClient { /** * Cancel your subscription * POST /v1/billing/subscription/cancel + * @deprecated Use @authsome/plugin-subscription instead. */ async cancelCurrentSubscription(body: CancelCurrentSubscriptionRequest, token: string): Promise { const path = "/v1/billing/subscription/cancel"; @@ -1745,6 +1764,7 @@ export class AuthClient { /** * Keep your subscription * POST /v1/billing/subscription/resume + * @deprecated Use @authsome/plugin-subscription instead. */ async resumeCurrentSubscription(body: ResumeCurrentSubscriptionRequest, token: string): Promise { const path = "/v1/billing/subscription/resume"; @@ -1759,6 +1779,7 @@ export class AuthClient { /** * List subscriptions * GET /v1/billing/subscriptions + * @deprecated Operator route. Call it from your server with @authsome/client, or use @authsome/plugin-subscription for tenant billing. */ async listSubscriptions(app_id: string, tenant_id: string, token: string, status?: string): Promise { const params = new URLSearchParams(); @@ -1778,6 +1799,7 @@ export class AuthClient { /** * Create subscription * POST /v1/billing/subscriptions + * @deprecated Operator route. Call it from your server with @authsome/client, or use @authsome/plugin-subscription for tenant billing. */ async createSubscription(body: CreateSubscriptionRequest, token: string): Promise { const path = "/v1/billing/subscriptions"; @@ -1792,6 +1814,7 @@ export class AuthClient { /** * Get active subscription * GET /v1/billing/subscriptions/active + * @deprecated Operator route. Call it from your server with @authsome/client, or use @authsome/plugin-subscription for tenant billing. */ async getActiveSubscription(app_id: string, tenant_id: string, token: string): Promise { const params = new URLSearchParams(); @@ -1810,6 +1833,7 @@ export class AuthClient { /** * Cancel subscription * POST /v1/billing/subscriptions/{subId}/cancel + * @deprecated Operator route. Call it from your server with @authsome/client, or use @authsome/plugin-subscription for tenant billing. */ async cancelSubscription(subId: string, body: CancelSubscriptionRequest, token: string): Promise { const path = `/v1/billing/subscriptions/${subId}/cancel`; @@ -1824,6 +1848,7 @@ export class AuthClient { /** * Change subscription plan * POST /v1/billing/subscriptions/{subId}/change-plan + * @deprecated Operator route. Call it from your server with @authsome/client, or use @authsome/plugin-subscription for tenant billing. */ async changeSubscriptionPlan(subId: string, body: ChangeSubscriptionPlanRequest, token: string): Promise { const path = `/v1/billing/subscriptions/${subId}/change-plan`; @@ -1838,6 +1863,7 @@ export class AuthClient { /** * Pause subscription * POST /v1/billing/subscriptions/{subId}/pause + * @deprecated Operator route. Call it from your server with @authsome/client, or use @authsome/plugin-subscription for tenant billing. */ async pauseSubscription(subId: string, token: string): Promise { const path = `/v1/billing/subscriptions/${subId}/pause`; @@ -1852,6 +1878,7 @@ export class AuthClient { /** * Resume subscription * POST /v1/billing/subscriptions/{subId}/resume + * @deprecated Operator route. Call it from your server with @authsome/client, or use @authsome/plugin-subscription for tenant billing. */ async resumeSubscription(subId: string, token: string): Promise { const path = `/v1/billing/subscriptions/${subId}/resume`; @@ -1866,6 +1893,7 @@ export class AuthClient { /** * Get usage summary * GET /v1/billing/usage + * @deprecated Operator route. Call it from your server with @authsome/client, or use @authsome/plugin-subscription for tenant billing. */ async getUsageSummary(app_id: string, tenant_id: string, token: string): Promise { const params = new URLSearchParams(); diff --git a/ui/packages/plugin-subscription/README.md b/ui/packages/plugin-subscription/README.md new file mode 100644 index 00000000..ced51dfb --- /dev/null +++ b/ui/packages/plugin-subscription/README.md @@ -0,0 +1,97 @@ +# @authsome/plugin-subscription + +The client half of authsome's `subscription` plugin. You get the tenant's subscription, its plan, seats and feature checks as typed models, React hooks around them, and a pricing table you can drop in. + +```bash +npm install @authsome/plugin-subscription @authsome/ui-react +``` + +The server needs the plugin too: `authsome.WithPlugin(subscription.New(...))`. + +## Set it up + +```tsx +import { AuthProvider } from "@authsome/ui-react"; +import { subscription } from "@authsome/plugin-subscription"; + + + + +``` + +## Hooks + +```tsx +import { useSubscription, usePlans, Entitled } from "@authsome/plugin-subscription/react"; + +function PlanBadge() { + const { plan, subscription, isLoading } = useSubscription(); + if (isLoading) return ; + if (!subscription) return Free; + return {plan.name}{subscription.willCancel && " (ending)"}; +} +``` + +`useSubscription()` loads on first use and gives you the subscription, its plan, `canManage`, and every write: `subscribe`, `changePlan`, `setSeats`, `cancel`, `resume` and `openPortal`. Writes update every component reading the subscription, so you never refetch by hand. `isPending` is true while one is in flight. + +A plan change either happens or needs paying for first: + +```ts +const result = await changePlan("team"); +if (result.type === "checkout") window.location.assign(result.url); +``` + +`` shows its children only when the plan includes the feature. While the check loads, on an error, or when the server doesn't have the plugin, you get the fallback: + +```tsx +}> + + +``` + +`usePlans()` and `useEntitlement(feature)` cover the rest. + +## Components + +```tsx +import { + PricingTable, + CurrentPlanCard, + UsageMeter, + ManageBillingButton, +} from "@authsome/plugin-subscription/components"; + + + + + +``` + +They're built on `@authsome/ui-components`, so they pick up the same theme as the sign-in forms. Buttons only show for someone who can manage billing. If you use Tailwind 4, add the package to your sources the way you do for `@authsome/ui-components`: + +```css +@source "../node_modules/@authsome/plugin-subscription/dist"; +``` + +`CurrentPlanCard` asks before it cancels. Every component, hook and `` takes a `tenantId` for support staff who hold `billing:manage` and look after a customer's billing: + +```tsx + +``` + +## Without React + +```ts +import { AuthManager } from "@authsome/ui-core"; +import { subscription } from "@authsome/plugin-subscription"; + +const auth = new AuthManager({ baseURL: url, plugins: [subscription()] }); +const billing = auth.plugin(subscription); + +await billing.load(); +billing.onChange(() => render(billing.getSnapshot())); +``` + +The snapshot follows the session: signing out clears it, and signing in or switching organization reloads it. A response for an organization you've already left is dropped rather than shown. `billing.forTenant(id)` gives you the same API pinned to another tenant, with its own cache. + +If the server doesn't have the subscription plugin, you get one console warning outside production, and the resources read as `unavailable` without a request. diff --git a/ui/packages/plugin-subscription/package.json b/ui/packages/plugin-subscription/package.json new file mode 100644 index 00000000..4f411eb3 --- /dev/null +++ b/ui/packages/plugin-subscription/package.json @@ -0,0 +1,79 @@ +{ + "name": "@authsome/plugin-subscription", + "version": "1.5.5", + "description": "Client plugin for authsome's subscription plugin: plans, subscriptions, seats and entitlements, with React hooks and components", + "license": "MIT", + "type": "module", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "https://github.com/xraph/authsome.git", + "directory": "ui/packages/plugin-subscription" + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "./react": { + "import": "./dist/react.js", + "types": "./dist/react.d.ts" + }, + "./components": { + "import": "./dist/components.js", + "types": "./dist/components.d.ts" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "rm -rf dist && tsup", + "dev": "tsup --watch", + "typecheck": "tsc --noEmit", + "clean": "rm -rf dist", + "lint": "eslint .", + "test": "vitest run" + }, + "dependencies": { + "@authsome/ui-core": "workspace:*" + }, + "peerDependencies": { + "@authsome/ui-components": "workspace:*", + "@authsome/ui-react": "workspace:*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@authsome/ui-components": { + "optional": true + }, + "@authsome/ui-react": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + }, + "devDependencies": { + "@authsome/ui-components": "workspace:*", + "@authsome/ui-react": "workspace:*", + "@testing-library/react": "^16.3.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.5", + "jsdom": "^28.0.1", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "tsup": "^8.5.1", + "typescript": "^5.9.3", + "vite": "^8.2.4", + "vitest": "^4.1.12" + } +} diff --git a/ui/packages/plugin-subscription/src/components.test.tsx b/ui/packages/plugin-subscription/src/components.test.tsx new file mode 100644 index 00000000..5d6cd3d6 --- /dev/null +++ b/ui/packages/plugin-subscription/src/components.test.tsx @@ -0,0 +1,185 @@ +import { act, fireEvent, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { CurrentPlanCard, ManageBillingButton, PricingTable, UsageMeter } from "./components"; +import { harness, json, planJSON, subJSON } from "./test-support"; + +describe("PricingTable", () => { + it("lists the plans with prices and marks the current one", async () => { + await harness().mount(); + await waitFor(() => expect(screen.getByText("Team")).toBeTruthy()); + expect(screen.getByText("$99.00")).toBeTruthy(); + expect(screen.getByText("Free")).toBeTruthy(); + expect((screen.getByRole("button", { name: "Current plan" }) as HTMLButtonElement).disabled).toBe(true); + }); + + it("changes plan when there's a subscription", async () => { + const h = harness(); + const patch = vi.fn(() => json({ subscription: subJSON({ plan: planJSON("team", { amount_minor: 9900 }) }) })); + h.routes.set("PATCH /v1/billing/subscription", patch); + await h.mount(); + const choose = await screen.findByRole("button", { name: "Choose Team" }); + await act(async () => { fireEvent.click(choose); }); + expect(patch).toHaveBeenCalledTimes(1); + await waitFor(() => expect(screen.getAllByRole("button", { name: "Current plan" })).toHaveLength(1)); + }); + + it("subscribes when there isn't one, and hands a checkout URL to onCheckout", async () => { + const h = harness(); + h.routes.set("GET /v1/billing/subscription", () => json({ subscription: null, can_manage: true })); + h.routes.set("POST /v1/billing/subscription", () => json({ checkout_url: "https://pay.example/s/1" })); + const onCheckout = vi.fn(); + await h.mount(); + const choose = await screen.findByRole("button", { name: "Choose Pro" }); + await act(async () => { fireEvent.click(choose); }); + expect(onCheckout).toHaveBeenCalledWith("https://pay.example/s/1"); + }); + + it("shows plain words, not the server's message, when a change fails", async () => { + const h = harness(); + h.routes.set("PATCH /v1/billing/subscription", () => json({ error: "already subscribed: use PATCH to change plan" }, 409)); + await h.mount(); + const choose = await screen.findByRole("button", { name: "Choose Team" }); + await act(async () => { fireEvent.click(choose); }); + await waitFor(() => expect(screen.getByRole("alert").textContent).toBe("Couldn't change your plan. Try again.")); + }); + + it("says which plan is highlighted, not only with colour", async () => { + await harness().mount(); + await waitFor(() => expect(screen.getByText("Recommended")).toBeTruthy()); + }); + + it("lets the app take over selection", async () => { + const onSelect = vi.fn(); + await harness().mount(); + const choose = await screen.findByRole("button", { name: "Choose Team" }); + fireEvent.click(choose); + expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ slug: "team" })); + }); + + it("hides the buttons from someone who can't change the plan", async () => { + const h = harness(); + h.routes.set("GET /v1/billing/subscription", () => json({ subscription: subJSON(), can_manage: false })); + await h.mount(); + await waitFor(() => expect(screen.getByText("Team")).toBeTruthy()); + expect(screen.queryByRole("button", { name: "Choose Team" })).toBeNull(); + }); +}); + +describe("CurrentPlanCard", () => { + it("shows the plan, price, renewal date and seats", async () => { + const h = harness(); + h.routes.set("GET /v1/billing/subscription", () => json({ + can_manage: true, + subscription: subJSON({ seats: { feature_key: "seats", used: 3, limit: 10 } }), + })); + await h.mount(); + await waitFor(() => expect(screen.getByText("Pro")).toBeTruthy()); + expect(screen.getByText("$29.00 / monthly")).toBeTruthy(); + expect(screen.getByText(/Renews on Dec 1, 2026/)).toBeTruthy(); + expect(screen.getByText("3 of 10 seats")).toBeTruthy(); + expect(screen.getByRole("button", { name: "Cancel plan" })).toBeTruthy(); + }); + + it("says when a canceled plan ends and offers to keep it", async () => { + const h = harness(); + h.routes.set("GET /v1/billing/subscription", () => json({ + can_manage: true, subscription: subJSON({ cancel_at: "2026-12-01T00:00:00Z" }), + })); + const resume = vi.fn(() => json({ subscription: subJSON() })); + h.routes.set("POST /v1/billing/subscription/resume", resume); + await h.mount(); + await waitFor(() => expect(screen.getByText(/Ends on Dec 1, 2026/)).toBeTruthy()); + await act(async () => { fireEvent.click(screen.getByRole("button", { name: "Keep plan" })); }); + expect(resume).toHaveBeenCalledTimes(1); + await waitFor(() => expect(screen.getByText(/Renews on/)).toBeTruthy()); + }); + + it("asks before canceling, and cancels only on yes", async () => { + const h = harness(); + const cancel = vi.fn(() => json({ subscription: subJSON({ cancel_at: "2026-12-01T00:00:00Z" }) })); + h.routes.set("POST /v1/billing/subscription/cancel", cancel); + await h.mount(); + fireEvent.click(await screen.findByRole("button", { name: "Cancel plan" })); + expect(cancel).not.toHaveBeenCalled(); + expect(screen.getByText("Cancel your plan? You keep it until Dec 1, 2026.")).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: "Never mind" })); + expect(screen.queryByText(/Cancel your plan\?/)).toBeNull(); + expect(cancel).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole("button", { name: "Cancel plan" })); + await act(async () => { fireEvent.click(screen.getByRole("button", { name: "Yes, cancel" })); }); + expect(cancel).toHaveBeenCalledTimes(1); + await waitFor(() => expect(screen.getByText(/Ends on Dec 1, 2026/)).toBeTruthy()); + }); + + it("shows and changes another tenant's plan when given a tenantId", async () => { + const h = harness(); + let body: unknown; + h.routes.set("POST /v1/billing/subscription/cancel", (init) => { + body = JSON.parse(String(init?.body)); + return json({ subscription: subJSON({ cancel_at: "2026-12-01T00:00:00Z" }) }); + }); + await h.mount(); + fireEvent.click(await screen.findByRole("button", { name: "Cancel plan" })); + await act(async () => { fireEvent.click(screen.getByRole("button", { name: "Yes, cancel" })); }); + expect(h.urls).toContain("GET /v1/billing/subscription?tenant_id=org_9"); + expect(body).toEqual({ when: "period_end", tenant_id: "org_9" }); + }); + + it("shows no buttons to someone who can't manage billing", async () => { + const h = harness(); + h.routes.set("GET /v1/billing/subscription", () => json({ subscription: subJSON(), can_manage: false })); + await h.mount(); + await waitFor(() => expect(screen.getByText("Pro")).toBeTruthy()); + expect(screen.queryByRole("button")).toBeNull(); + }); + + it("has an empty state", async () => { + const h = harness(); + h.routes.set("GET /v1/billing/subscription", () => json({ subscription: null, can_manage: true })); + await h.mount(); + await waitFor(() => expect(screen.getByText("No plan yet")).toBeTruthy()); + }); +}); + +describe("UsageMeter", () => { + it("shows use against the limit", async () => { + await harness().mount(); + await waitFor(() => expect(screen.getByText("2 of 10")).toBeTruthy()); + expect(screen.getByText("SSO connections")).toBeTruthy(); + expect(screen.getByRole("progressbar").getAttribute("aria-valuenow")).toBe("2"); + expect(screen.getByRole("progressbar").getAttribute("aria-valuetext")).toBe("2 of 10"); + }); + + it("describes unlimited use in words", async () => { + const h = harness(); + h.routes.set("GET /v1/billing/entitlements", () => json({ entitlements: { api: { allowed: true, feature: "api", used: 40, limit: -1, remaining: -1 } } })); + await h.mount(); + await waitFor(() => expect(screen.getByText("40 used")).toBeTruthy()); + const bar = screen.getByRole("progressbar"); + expect(bar.getAttribute("aria-valuetext")).toBe("40 used, no limit"); + expect(bar.getAttribute("aria-valuenow")).toBeNull(); + }); +}); + +describe("ManageBillingButton", () => { + it("opens the portal", async () => { + const h = harness(); + h.routes.set("POST /v1/billing/portal", () => json({ url: "https://pay.example/portal" })); + const onOpen = vi.fn(); + await h.mount(); + const button = await screen.findByRole("button", { name: "Manage billing" }); + await act(async () => { fireEvent.click(button); }); + expect(onOpen).toHaveBeenCalledWith("https://pay.example/portal"); + }); + + it("isn't shown to someone who can't manage billing", async () => { + const h = harness(); + h.routes.set("GET /v1/billing/subscription", () => json({ subscription: subJSON(), can_manage: false })); + await h.mount(); + await waitFor(() => expect(h.calls).toContain("GET /v1/billing/subscription")); + expect(screen.queryByRole("button")).toBeNull(); + }); +}); diff --git a/ui/packages/plugin-subscription/src/components.tsx b/ui/packages/plugin-subscription/src/components.tsx new file mode 100644 index 00000000..880f1b71 --- /dev/null +++ b/ui/packages/plugin-subscription/src/components.tsx @@ -0,0 +1,372 @@ +/** + * Drop-in billing components, styled like @authsome/ui-components (whose + * stylesheet you already load for the auth forms). + * + * ```tsx + * + * + * + * + * ``` + */ + +import { + Badge, + Button, + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, + Skeleton, + cn, +} from "@authsome/ui-components"; +import { useState, type ReactNode } from "react"; + +import { formatMoney, type Plan, type Subscription } from "./models"; +import type { ChangeResult } from "./plugin"; +import { useEntitlement, usePlans, useSubscription, type TenantOptions } from "./react"; + +const redirect = (url: string) => window.location.assign(url); + +function formatDate(iso: string, locale?: string, timeZone?: string): string { + return new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeZone }).format(new Date(iso)); +} + +function ErrorLine({ children }: { children: ReactNode }) { + return ( +

+ {children} +

+ ); +} + +// ── PricingTable ────────────────────────────────── + +export interface PricingTableProps extends TenantOptions { + /** Take over selection yourself. Without it the table subscribes or changes plan. */ + onSelect?: (plan: Plan) => void; + /** Called with the payment page URL when a plan needs paying for. Defaults to redirecting. */ + onCheckout?: (url: string) => void; + /** Plan slug to draw attention to. */ + highlight?: string; + /** Where the payment page sends the customer back to. */ + returnUrl?: string; + /** Where it sends them if they back out. */ + cancelUrl?: string; + /** Locale for prices. Defaults to the browser's. */ + locale?: string; + /** Called with the server's error when a change fails; the table shows plain words. */ + onError?: (error: unknown) => void; + className?: string; +} + +/** The plans you can buy, cheapest first, with a button on each. */ +export function PricingTable({ + onSelect, + onCheckout = redirect, + highlight, + returnUrl, + cancelUrl, + locale, + onError, + className, + tenantId, +}: PricingTableProps) { + const { plans, isLoading, error } = usePlans(); + const { subscription, canManage, subscribe, changePlan, isPending } = useSubscription({ tenantId }); + const [failed, setFailed] = useState(false); + + const choose = async (plan: Plan) => { + if (onSelect) { + onSelect(plan); + return; + } + setFailed(false); + try { + const options = { returnUrl, cancelUrl }; + const result: ChangeResult = subscription + ? await changePlan(plan.slug, options) + : await subscribe(plan.slug, options); + if (result.type === "checkout") onCheckout(result.url); + } catch (err) { + setFailed(true); + onError?.(err); + } + }; + + if (isLoading) { + return ( +
+ {[0, 1, 2].map((i) => ( + + ))} +
+ ); + } + if (error) return Couldn't load plans.; + + const showButtons = canManage || Boolean(onSelect); + return ( +
+
+ {plans.map((plan) => { + const current = subscription?.plan.id === plan.id; + return ( + + +
+ {plan.name} + {plan.slug === highlight && Recommended} +
+ {plan.description && {plan.description}} +
+ +

+ + {plan.price ? formatMoney(plan.price, locale) : "No charge"} + + {plan.price?.interval && ( + / {plan.price.interval} + )} +

+ {plan.trialDays > 0 && !subscription && ( + {plan.trialDays}-day trial + )} + {plan.features.length > 0 && ( +
    + {plan.features.map((f) => ( +
  • + {f.name} + {f.type !== "boolean" && ( + + {" "} + {f.limit === null ? "unlimited" : `up to ${f.limit}`} + + )} +
  • + ))} +
+ )} +
+ {showButtons && ( + + + + )} +
+ ); + })} +
+ {failed && Couldn't change your plan. Try again.} +
+ ); +} + +// ── CurrentPlanCard ─────────────────────────────── + +export interface CurrentPlanCardProps extends TenantOptions { + /** Locale for prices and dates. Defaults to the browser's. */ + locale?: string; + /** Time zone for dates. Defaults to the browser's. */ + timeZone?: string; + /** Called with the server's error when cancel or keep fails; the card shows plain words. */ + onError?: (error: unknown) => void; + className?: string; +} + +function statusLine(sub: Subscription, locale?: string, timeZone?: string): string { + const date = (iso: string) => formatDate(iso, locale, timeZone); + if (sub.isPaused) return "Paused"; + if (sub.willCancel && sub.cancelAt) return `Ends on ${date(sub.cancelAt)}`; + if (sub.isTrialing && sub.trialEnd) return `Trial ends on ${date(sub.trialEnd)}`; + if (sub.status === "past_due") return "Payment is overdue"; + return `Renews on ${date(sub.currentPeriodEnd)}`; +} + +/** The tenant's plan: name, price, when it renews or ends, seats, and cancel/keep. */ +export function CurrentPlanCard({ locale, timeZone, onError, className, tenantId }: CurrentPlanCardProps) { + const { subscription: sub, canManage, isLoading, error, cancel, resume, isPending } = + useSubscription({ tenantId }); + const [failed, setFailed] = useState(null); + // Canceling is the one write here that's hard to take back once the + // period ends, so it asks first. + const [confirming, setConfirming] = useState(false); + + const run = async (write: () => Promise) => { + setConfirming(false); + setFailed(null); + try { + await write(); + } catch (err) { + setFailed("Couldn't update your plan. Try again."); + onError?.(err); + } + }; + + if (isLoading) return ; + if (error) return Couldn't load your plan.; + + if (!sub) { + return ( + + + No plan yet + Pick a plan to get started. + + + ); + } + + return ( + + +
+ {sub.plan.name} + {sub.isTrialing && Trial} + {sub.status === "past_due" && Past due} +
+ {sub.plan.price && ( + + {`${formatMoney(sub.plan.price, locale)} / ${sub.plan.price.interval}`} + + )} +
+ +

{statusLine(sub, locale, timeZone)}

+ {sub.seats && ( +

+ {sub.seats.limit === null + ? `${sub.seats.used} seats` + : `${sub.seats.used} of ${sub.seats.limit} seats`} +

+ )} + {failed && {failed}} +
+ {canManage && !sub.isPaused && ( + + {sub.willCancel ? ( + + ) : confirming ? ( + <> +

+ {`Cancel your plan? You keep it until ${formatDate(sub.currentPeriodEnd, locale, timeZone)}.`} +

+ + + + ) : ( + + )} +
+ )} +
+ ); +} + +// ── UsageMeter ──────────────────────────────────── + +export interface UsageMeterProps extends TenantOptions { + /** The metered plan feature, e.g. "api_calls". */ + feature: string; + /** What to call it. Defaults to the feature key. */ + label?: string; + className?: string; +} + +/** How much of a metered feature is used, against the plan's limit. */ +export function UsageMeter({ feature, label, className, tenantId }: UsageMeterProps) { + const { entitlement, isLoading } = useEntitlement(feature, { tenantId }); + if (isLoading) return ; + if (!entitlement) return null; + + const unlimited = entitlement.limit < 0; + const percent = unlimited || entitlement.limit === 0 + ? 0 + : Math.min(100, Math.round((entitlement.used / entitlement.limit) * 100)); + return ( +
+
+ {label ?? feature} + + {unlimited ? `${entitlement.used} used` : `${entitlement.used} of ${entitlement.limit}`} + +
+
+
= 90 && "bg-destructive")} + style={{ width: `${percent}%` }} + /> +
+
+ ); +} + +// ── ManageBillingButton ─────────────────────────── + +export interface ManageBillingButtonProps extends TenantOptions { + /** Where the portal sends the customer back to. */ + returnUrl?: string; + /** Called with the portal URL. Defaults to redirecting. */ + onOpen?: (url: string) => void; + children?: ReactNode; + className?: string; +} + +/** + * Opens the payment provider's billing portal. Shown only to someone who can + * manage billing; a server without a portal answers with an error message. + */ +export function ManageBillingButton({ + returnUrl, + onOpen = redirect, + children = "Manage billing", + className, + tenantId, +}: ManageBillingButtonProps) { + const { canManage, openPortal, isPending } = useSubscription({ tenantId }); + const [failed, setFailed] = useState(false); + if (!canManage) return null; + + const open = async () => { + setFailed(false); + try { + onOpen(await openPortal(returnUrl)); + } catch { + setFailed(true); + } + }; + + return ( +
+ + {failed && Couldn't open the billing portal.} +
+ ); +} diff --git a/ui/packages/plugin-subscription/src/index.ts b/ui/packages/plugin-subscription/src/index.ts new file mode 100644 index 00000000..add851d2 --- /dev/null +++ b/ui/packages/plugin-subscription/src/index.ts @@ -0,0 +1,37 @@ +/** + * @authsome/plugin-subscription: the client half of authsome's + * subscription plugin. + * + * ```ts + * import { subscription } from "@authsome/plugin-subscription"; + * + * const auth = new AuthManager({ baseURL, plugins: [subscription()] }); + * const billing = auth.plugin(subscription); + * await billing.load(); + * ``` + * + * React hooks live in "@authsome/plugin-subscription/react" and styled + * components in "@authsome/plugin-subscription/components". + */ + +export { + subscription, + createSubscriptionApi, + type ChangeOptions, + type ChangeResult, + type Resource, + type SubscriptionApi, + type SubscriptionSnapshot, +} from "./plugin"; +export { + currencyDecimals, + formatMoney, + type Entitlement, + type Money, + type Plan, + type PlanFeature, + type Price, + type Seats, + type Subscription, + type SubscriptionStatus, +} from "./models"; diff --git a/ui/packages/plugin-subscription/src/models.ts b/ui/packages/plugin-subscription/src/models.ts new file mode 100644 index 00000000..d4571aed --- /dev/null +++ b/ui/packages/plugin-subscription/src/models.ts @@ -0,0 +1,235 @@ +/** + * Typed models for the subscription plugin, mapped from the server's JSON. + * Field names are camelCase; times stay RFC 3339 strings, so they survive + * serialization (server components, caches) unchanged. + */ + +/** A subscription's state. Unknown values from a newer server pass through. */ +export type SubscriptionStatus = + | "active" + | "trialing" + | "past_due" + | "canceled" + | "expired" + | "paused" + | (string & {}); + +/** An amount in the currency's minor unit (cents for usd). */ +export interface Money { + amountMinor: number; + currency: string; +} + +/** A plan's base price and how often it's charged. */ +export interface Price extends Money { + /** "monthly", "yearly", ... as the server names it. */ + interval: string; +} + +/** One feature a plan includes. A null limit means unlimited. */ +export interface PlanFeature { + key: string; + name: string; + /** "boolean", "metered" or "seat". */ + type: string; + limit: number | null; + period?: string; + softLimit: boolean; +} + +export interface Plan { + id: string; + slug: string; + name: string; + description: string; + status: string; + trialDays: number; + isAddon: boolean; + /** Null for a plan without a price. */ + price: Price | null; + features: PlanFeature[]; + metadata: Record; +} + +/** Seat use on a plan with a seat feature. A null limit means unlimited. */ +export interface Seats { + featureKey: string; + used: number; + limit: number | null; +} + +export interface Subscription { + id: string; + tenantId: string; + status: SubscriptionStatus; + plan: Plan; + currentPeriodStart: string; + currentPeriodEnd: string; + trialStart: string | null; + trialEnd: string | null; + /** When a cancellation scheduled for the period end takes effect. */ + cancelAt: string | null; + canceledAt: string | null; + pausedAt: string | null; + seats: Seats | null; + /** The tenant has the plan's features: active, trialing, or past due. */ + isActive: boolean; + isTrialing: boolean; + isPaused: boolean; + /** A cancellation is scheduled; the plan runs until cancelAt. */ + willCancel: boolean; + /** Whole days of trial left, null when not trialing. */ + trialDaysLeft: number | null; +} + +/** Whether the tenant may use a feature, and how much of it is left. */ +export interface Entitlement { + allowed: boolean; + used: number; + /** -1 or a large number from the server means unlimited, as ledger reports it. */ + limit: number; + remaining: number; + reason?: string; +} + +// ── Mapping from the server's JSON ──────────────── + +interface PlanFeatureJSON { + key: string; + name: string; + type: string; + limit: number | null; + period?: string; + soft_limit?: boolean; +} + +export interface PlanJSON { + id: string; + slug: string; + name: string; + description?: string; + status: string; + trial_days?: number; + is_addon?: boolean; + currency: string; + amount_minor?: number; + billing_period?: string; + features?: PlanFeatureJSON[]; + metadata?: Record; +} + +export interface SubscriptionJSON { + id: string; + tenant_id: string; + status: string; + plan: PlanJSON; + current_period_start: string; + current_period_end: string; + trial_start?: string | null; + trial_end?: string | null; + cancel_at?: string | null; + canceled_at?: string | null; + paused_at?: string | null; + seats?: { feature_key: string; used: number; limit: number | null } | null; +} + +export interface EntitlementJSON { + allowed: boolean; + used: number; + limit: number; + remaining: number; + reason?: string; +} + +const DAY_MS = 86_400_000; + +export function toPlan(p: PlanJSON): Plan { + return { + id: p.id, + slug: p.slug, + name: p.name, + description: p.description ?? "", + status: p.status, + trialDays: p.trial_days ?? 0, + isAddon: p.is_addon ?? false, + price: + p.amount_minor === undefined || p.amount_minor === null + ? null + : { amountMinor: p.amount_minor, currency: p.currency, interval: p.billing_period ?? "" }, + features: (p.features ?? []).map((f) => ({ + key: f.key, + name: f.name, + type: f.type, + limit: f.limit ?? null, + ...(f.period ? { period: f.period } : {}), + softLimit: f.soft_limit ?? false, + })), + metadata: p.metadata ?? {}, + }; +} + +export function toSubscription(s: SubscriptionJSON, now = Date.now()): Subscription { + const trialEnd = s.trial_end ?? null; + const cancelAt = s.cancel_at ?? null; + const trialing = s.status === "trialing"; + return { + id: s.id, + tenantId: s.tenant_id, + status: s.status, + plan: toPlan(s.plan), + currentPeriodStart: s.current_period_start, + currentPeriodEnd: s.current_period_end, + trialStart: s.trial_start ?? null, + trialEnd, + cancelAt, + canceledAt: s.canceled_at ?? null, + pausedAt: s.paused_at ?? null, + seats: s.seats + ? { featureKey: s.seats.feature_key, used: s.seats.used, limit: s.seats.limit ?? null } + : null, + isActive: s.status === "active" || trialing || s.status === "past_due", + isTrialing: trialing, + isPaused: s.status === "paused", + willCancel: cancelAt !== null, + trialDaysLeft: + trialing && trialEnd + ? Math.max(0, Math.ceil((Date.parse(trialEnd) - now) / DAY_MS)) + : null, + }; +} + +export function toEntitlement(e: EntitlementJSON): Entitlement { + return { + allowed: e.allowed, + used: e.used, + limit: e.limit, + remaining: e.remaining, + ...(e.reason ? { reason: e.reason } : {}), + }; +} + +/** + * Currencies the server's ledger stores with no minor unit. Every other + * currency it stores in hundredths, whatever the currency's ISO digits are, + * so this list has to match ledger's, not ISO 4217's. + */ +const ZERO_DECIMAL = new Set(["jpy", "krw", "vnd", "clp", "pyg", "idr"]); + +/** Decimal places the server uses for a currency's amounts: 0 or 2. */ +export function currencyDecimals(currency: string): number { + return ZERO_DECIMAL.has(currency.toLowerCase()) ? 0 : 2; +} + +/** + * Formats an amount for display. amountMinor is split the way the server + * stored it (see currencyDecimals) and shown with that many decimals. + */ +export function formatMoney(money: Money, locale?: string): string { + const digits = currencyDecimals(money.currency); + return new Intl.NumberFormat(locale, { + style: "currency", + currency: money.currency.toUpperCase(), + minimumFractionDigits: digits, + maximumFractionDigits: digits, + }).format(money.amountMinor / 10 ** digits); +} diff --git a/ui/packages/plugin-subscription/src/plugin.test.ts b/ui/packages/plugin-subscription/src/plugin.test.ts new file mode 100644 index 00000000..508e55b5 --- /dev/null +++ b/ui/packages/plugin-subscription/src/plugin.test.ts @@ -0,0 +1,393 @@ +import { AuthManager, type ClientConfig, type Session } from "@authsome/ui-core"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { formatMoney, subscription, type Subscription } from "./index"; + +const managers: AuthManager[] = []; +afterEach(() => managers.splice(0).forEach((m) => m.destroy())); +const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status }); + +const inAnHour = () => new Date(Date.now() + 3_600_000).toISOString(); + +export function planJSON(slug: string, overrides: Record = {}) { + return { + id: `plan_${slug}`, name: slug[0].toUpperCase() + slug.slice(1), slug, description: "", + currency: "usd", status: "active", trial_days: 0, features_count: 1, is_addon: false, app_id: "app_1", + base_amount: "29.00", billing_period: "monthly", amount_minor: 2900, + features: [{ key: "sso", name: "SSO", type: "boolean", limit: 1, soft_limit: false }], + ...overrides, + }; +} + +export function subJSON(overrides: Record = {}) { + return { + id: "sub_1", tenant_id: "org_1", status: "active", plan: planJSON("pro"), + current_period_start: "2026-11-01T00:00:00Z", current_period_end: "2026-12-01T00:00:00Z", + trial_start: null, trial_end: null, cancel_at: null, canceled_at: null, paused_at: null, + app_id: "app_1", + ...overrides, + }; +} + +type Handler = (init?: RequestInit, url?: URL) => Response | Promise; + +function setup(opts: { serverPlugins?: string[]; me?: Record; appId?: string } = {}) { + const session: Session = { session_token: "tok", refresh_token: "ref", expires_at: inAnHour() }; + const store = new Map([["authsome:session", JSON.stringify(session)]]); + const routes = new Map([ + ["GET /v1/me", () => json({ id: "usr_1", email: "a@test", app_id: "app_user", active_org_id: "org_1", ...opts.me })], + ["POST /v1/signout", () => json({ status: "ok" })], + ["GET /v1/billing/subscription", () => json({ subscription: subJSON(), can_manage: true })], + ["GET /v1/billing/entitlements", () => json({ entitlements: { sso: { allowed: true, feature: "sso", used: 0, limit: 1, remaining: 1 } } })], + ]); + const calls: string[] = []; + const fetch = vi.fn(async (input, init) => { + const url = new URL(String(input)); + const key = `${init?.method ?? "GET"} ${url.pathname}`; + calls.push(`${key}${url.search}`); + const handler = routes.get(key); + if (!handler) return json({ error: `no route ${key}` }, 404); + return handler(init, url); + }); + const initialClientConfig = opts.serverPlugins + ? ({ supported_plugins: opts.serverPlugins, app_id: opts.appId } as unknown as ClientConfig) + : opts.appId ? ({ app_id: opts.appId } as unknown as ClientConfig) : undefined; + const manager = new AuthManager({ + baseURL: "https://api.test", fetch, initialClientConfig, + plugins: [subscription()], + storage: { + getItem: (k) => store.get(k) ?? null, + setItem: (k, v) => { store.set(k, v); }, + removeItem: (k) => { store.delete(k); }, + }, + }); + managers.push(manager); + return { manager, routes, calls, api: () => manager.plugin(subscription) }; +} + +describe("models", () => { + it("maps the server's view into typed, camelCase models", async () => { + const { manager, routes, api } = setup(); + routes.set("GET /v1/billing/subscription", () => json({ + can_manage: false, + subscription: subJSON({ + status: "trialing", trial_end: new Date(Date.now() + 3 * 86_400_000).toISOString(), + cancel_at: "2026-12-01T00:00:00Z", + seats: { feature_key: "seats", used: 3, limit: null }, + plan: planJSON("team", { features: [{ key: "api", name: "API", type: "metered", limit: null, soft_limit: true, period: "monthly" }] }), + }), + })); + await manager.initialize(); + await api().load(); + + const snap = api().getSnapshot(); + const sub = snap.subscription.data as Subscription; + expect(snap.subscription.status).toBe("ready"); + expect(snap.canManage).toBe(false); + expect(sub.plan.slug).toBe("team"); + expect(sub.plan.price).toEqual({ amountMinor: 2900, currency: "usd", interval: "monthly" }); + expect(sub.plan.features[0]).toEqual({ key: "api", name: "API", type: "metered", limit: null, period: "monthly", softLimit: true }); + expect(sub.seats).toEqual({ featureKey: "seats", used: 3, limit: null }); + expect(sub.isTrialing).toBe(true); + expect(sub.isActive).toBe(true); + expect(sub.willCancel).toBe(true); + expect(sub.cancelAt).toBe("2026-12-01T00:00:00Z"); + expect(sub.trialDaysLeft).toBe(3); + }); + + it("formats money in minor units by the currency's digits", () => { + expect(formatMoney({ amountMinor: 2900, currency: "usd" }, "en-US")).toBe("$29.00"); + expect(formatMoney({ amountMinor: 500, currency: "jpy" }, "en-US")).toBe("¥500"); + }); + + it("splits minor units the way the server's ledger does", () => { + // Ledger stores these with no decimals and everything else with two, + // whatever the currency's ISO digits are. + expect(formatMoney({ amountMinor: 5000, currency: "idr" }, "en-US")).toMatch(/5,000(?!\.)/); + expect(formatMoney({ amountMinor: 150, currency: "kwd" }, "en-US")).toMatch(/1\.50$/); + expect(formatMoney({ amountMinor: 1050, currency: "ugx" }, "en-US")).toMatch(/10\.50$/); + expect(formatMoney({ amountMinor: 1050, currency: "huf" }, "en-US")).toMatch(/10\.50$/); + }); +}); + +describe("loading", () => { + it("loads the subscription, can_manage and entitlements together", async () => { + const { manager, api } = setup(); + await manager.initialize(); + await api().load(); + const snap = api().getSnapshot(); + expect(snap.subscription.data?.id).toBe("sub_1"); + expect(snap.canManage).toBe(true); + expect(snap.entitlements.status).toBe("ready"); + expect(api().entitlement("sso")?.allowed).toBe(true); + expect(api().entitlement("nope")).toBeNull(); + }); + + it("is unavailable, without asking, when the server has no subscription plugin", async () => { + const { manager, api, calls } = setup({ serverPlugins: ["organization"] }); + await manager.initialize(); + await api().load(); + expect(api().getSnapshot().subscription.status).toBe("unavailable"); + expect(api().getSnapshot().entitlements.status).toBe("unavailable"); + expect(calls.some((c) => c.includes("/v1/billing"))).toBe(false); + }); + + it("drops a response for the org the session has since left", async () => { + const { manager, routes, api } = setup(); + await manager.initialize(); + let release!: () => void; + const gate = new Promise((r) => (release = r)); + routes.set("GET /v1/billing/subscription", async () => { + await gate; + return json({ subscription: subJSON({ tenant_id: "org_1" }), can_manage: true }); + }); + const loading = api().load(); + + routes.set("POST /v1/me/switch-org", () => json({ session_id: "s", org_id: "org_2" })); + routes.set("GET /v1/billing/subscription", () => json({ subscription: null, can_manage: true })); + await manager.switchOrg("org_2"); + release(); + await loading; + await vi.waitFor(() => expect(api().getSnapshot().subscription.status).toBe("ready")); + expect(api().getSnapshot().subscription.data).toBeNull(); + }); + + it("shares one request between loads that overlap", async () => { + const { manager, api, calls } = setup(); + await manager.initialize(); + await Promise.all([api().load(), api().load(), api().load()]); + expect(calls.filter((c) => c.startsWith("GET /v1/billing/subscription"))).toHaveLength(1); + expect(calls.filter((c) => c.startsWith("GET /v1/billing/entitlements"))).toHaveLength(1); + }); + + it("forgets the old org's subscription as soon as the org switches", async () => { + const { manager, routes, api } = setup(); + await manager.initialize(); + await api().load(); + expect(api().getSnapshot().canManage).toBe(true); + routes.set("POST /v1/me/switch-org", () => json({ session_id: "s", org_id: "org_2" })); + routes.set("GET /v1/billing/subscription", () => new Promise(() => {})); + + await manager.switchOrg("org_2"); + const snap = api().getSnapshot(); + expect(snap.subscription.status).toBe("loading"); + expect(snap.subscription.data).toBeNull(); + expect(snap.canManage).toBe(false); + }); + + it("ignores a plans response that lands after sign-out", async () => { + const { manager, routes, api } = setup({ appId: "app_1" }); + await manager.initialize(); + let release!: () => void; + const gate = new Promise((r) => (release = r)); + routes.set("GET /v1/billing/plans", async () => { + await gate; + return json({ plans: [planJSON("pro")] }); + }); + const loading = api().loadPlans(); + await manager.signOut(); + release(); + await loading; + expect(api().getSnapshot().plans.status).toBe("idle"); + expect(api().getSnapshot().plans.data).toEqual([]); + }); + + it("resets on sign-out and reloads on an org switch", async () => { + const { manager, routes, api, calls } = setup(); + await manager.initialize(); + await api().load(); + routes.set("POST /v1/me/switch-org", () => json({ session_id: "s", org_id: "org_2" })); + const before = calls.filter((c) => c.startsWith("GET /v1/billing/subscription")).length; + await manager.switchOrg("org_2"); + await vi.waitFor(() => + expect(calls.filter((c) => c.startsWith("GET /v1/billing/subscription")).length).toBe(before + 1)); + + await manager.signOut(); + expect(api().getSnapshot().subscription.status).toBe("idle"); + expect(api().getSnapshot().subscription.data).toBeNull(); + }); + + it("reads plans for the client config's app, else the user's", async () => { + const withConfig = setup({ appId: "app_cfg" }); + withConfig.routes.set("GET /v1/billing/plans", () => json({ plans: [planJSON("free", { amount_minor: undefined, base_amount: undefined, billing_period: undefined }), planJSON("pro")], total: 2 })); + await withConfig.manager.initialize(); + await withConfig.api().loadPlans(); + expect(withConfig.calls).toContain("GET /v1/billing/plans?app_id=app_cfg"); + expect(withConfig.api().getSnapshot().plans.data.map((p) => p.slug)).toEqual(["free", "pro"]); + expect(withConfig.api().getSnapshot().plans.data[0].price).toBeNull(); + + const fromUser = setup(); + fromUser.routes.set("GET /v1/billing/plans", () => json({ plans: [], total: 0 })); + await fromUser.manager.initialize(); + await fromUser.api().loadPlans(); + expect(fromUser.calls).toContain("GET /v1/billing/plans?app_id=app_user"); + }); +}); + +describe("writes", () => { + it("writes a changed subscription into the cache", async () => { + const { manager, routes, api } = setup(); + await manager.initialize(); + await api().load(); + routes.set("PATCH /v1/billing/subscription", (init) => { + expect(JSON.parse(String(init?.body))).toEqual({ plan: "team", seats: 5 }); + return json({ subscription: subJSON({ plan: planJSON("team") }) }); + }); + + const result = await api().changePlan("team", { seats: 5 }); + expect(result.type).toBe("changed"); + expect(api().getSnapshot().subscription.data?.plan.slug).toBe("team"); + }); + + it("leaves the cache alone when payment comes first", async () => { + const { manager, routes, api } = setup(); + await manager.initialize(); + await api().load(); + routes.set("POST /v1/billing/subscription", () => json({ checkout_url: "https://pay.example/1" })); + + const result = await api().subscribe("business", { returnUrl: "https://app/billing" }); + expect(result).toEqual({ type: "checkout", url: "https://pay.example/1" }); + expect(api().getSnapshot().subscription.data?.plan.slug).toBe("pro"); + }); + + it("surfaces a refused write without touching the cache", async () => { + const { manager, routes, api } = setup(); + await manager.initialize(); + await api().load(); + routes.set("POST /v1/billing/subscription/cancel", () => json({ error: "your role can't manage billing for this organization" }, 403)); + + await expect(api().cancel()).rejects.toMatchObject({ code: 403 }); + expect(api().getSnapshot().subscription.data?.cancelAt).toBeNull(); + }); + + it("cancels at the period end by default and resumes", async () => { + const { manager, routes, api } = setup(); + await manager.initialize(); + await api().load(); + routes.set("POST /v1/billing/subscription/cancel", (init) => { + expect(JSON.parse(String(init?.body))).toEqual({ when: "period_end" }); + return json({ subscription: subJSON({ cancel_at: "2026-12-01T00:00:00Z" }) }); + }); + routes.set("POST /v1/billing/subscription/resume", () => json({ subscription: subJSON() })); + + expect((await api().cancel()).willCancel).toBe(true); + expect(api().getSnapshot().subscription.data?.willCancel).toBe(true); + expect((await api().resume()).willCancel).toBe(false); + }); + + it("keeps a write that lands after an org switch out of the new org's cache", async () => { + const { manager, routes, api } = setup(); + await manager.initialize(); + await api().load(); + let release!: () => void; + const gate = new Promise((r) => (release = r)); + routes.set("POST /v1/billing/subscription/cancel", async () => { + await gate; + return json({ subscription: subJSON({ cancel_at: "2026-12-01T00:00:00Z" }) }); + }); + const canceling = api().cancel(); + + routes.set("POST /v1/me/switch-org", () => json({ session_id: "s", org_id: "org_2" })); + routes.set("GET /v1/billing/subscription", () => json({ subscription: null, can_manage: true })); + await manager.switchOrg("org_2"); + await vi.waitFor(() => expect(api().getSnapshot().subscription.status).toBe("ready")); + release(); + + expect((await canceling).willCancel).toBe(true); + expect(api().getSnapshot().subscription.data).toBeNull(); + }); + + it("keeps a write that lands after sign-out out of the cache", async () => { + const { manager, routes, api } = setup(); + await manager.initialize(); + await api().load(); + let release!: () => void; + const gate = new Promise((r) => (release = r)); + routes.set("POST /v1/billing/subscription/resume", async () => { + await gate; + return json({ subscription: subJSON() }); + }); + const resuming = api().resume(); + await manager.signOut(); + release(); + await resuming; + expect(api().getSnapshot().subscription.status).toBe("idle"); + expect(api().getSnapshot().subscription.data).toBeNull(); + }); + + it("notifies listeners on every change", async () => { + const { manager, api } = setup(); + await manager.initialize(); + const listener = vi.fn(); + api().onChange(listener); + await api().load(); + expect(listener).toHaveBeenCalled(); + }); +}); + +describe("a server without the plugin", () => { + it("warns once, naming the fix", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + const { manager, api } = setup({ serverPlugins: ["organization"] }); + await manager.initialize(); + await api().load(); + await api().load(); + await api().loadPlans(); + await api().forTenant("org_9").load(); + expect(warn).toHaveBeenCalledTimes(1); + expect(String(warn.mock.calls[0][0])).toContain("subscription.New"); + } finally { + warn.mockRestore(); + } + }); + + it("stays quiet when the server has it", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + const { manager, api } = setup({ serverPlugins: ["subscription"] }); + await manager.initialize(); + await api().load(); + expect(warn).not.toHaveBeenCalled(); + } finally { + warn.mockRestore(); + } + }); +}); + +describe("forTenant", () => { + it("reads and writes another tenant, with its own cache", async () => { + const { manager, routes, calls, api } = setup(); + let cancelBody: unknown; + routes.set("POST /v1/billing/subscription/cancel", (init) => { + cancelBody = JSON.parse(String(init?.body)); + return json({ subscription: subJSON({ tenant_id: "org_9", cancel_at: "2026-12-01T00:00:00Z" }) }); + }); + await manager.initialize(); + const other = api().forTenant("org_9"); + expect(api().forTenant("org_9")).toBe(other); + expect(other.tenantId).toBe("org_9"); + expect(api().tenantId).toBeNull(); + + await other.load(); + expect(calls).toContain("GET /v1/billing/subscription?tenant_id=org_9"); + expect(calls).toContain("GET /v1/billing/entitlements?tenant_id=org_9"); + expect(api().getSnapshot().subscription.status).toBe("idle"); + + await other.cancel(); + expect(cancelBody).toEqual({ when: "period_end", tenant_id: "org_9" }); + expect(other.getSnapshot().subscription.data?.willCancel).toBe(true); + }); + + it("keeps a pinned tenant across org switches and forgets it on sign-out", async () => { + const { manager, routes, api } = setup(); + await manager.initialize(); + const other = api().forTenant("org_9"); + await other.load(); + routes.set("POST /v1/me/switch-org", () => json({ session_id: "s", org_id: "org_2" })); + await manager.switchOrg("org_2"); + expect(other.getSnapshot().subscription.status).toBe("ready"); + await manager.signOut(); + expect(other.getSnapshot().subscription.status).toBe("idle"); + }); +}); diff --git a/ui/packages/plugin-subscription/src/plugin.ts b/ui/packages/plugin-subscription/src/plugin.ts new file mode 100644 index 00000000..c04e6d06 --- /dev/null +++ b/ui/packages/plugin-subscription/src/plugin.ts @@ -0,0 +1,366 @@ +import { definePlugin, type PluginContext } from "@authsome/ui-core"; + +import { + toEntitlement, + toPlan, + toSubscription, + type Entitlement, + type EntitlementJSON, + type Plan, + type PlanJSON, + type Subscription, + type SubscriptionJSON, +} from "./models"; + +/** + * One piece of server state. "unavailable" means the server doesn't have the + * subscription plugin installed; treat it like a plan without the feature. + */ +export interface Resource { + status: "idle" | "loading" | "ready" | "error" | "unavailable"; + data: T; + error: Error | null; +} + +/** Everything the plugin knows, as one immutable snapshot. */ +export interface SubscriptionSnapshot { + /** The tenant's subscription; data is null when it has none. */ + subscription: Resource; + /** Whether the signed-in user may change the subscription. */ + canManage: boolean; + /** Plans the tenant can buy, cheapest first. */ + plans: Resource; + /** Every feature on the current plan, checked. */ + entitlements: Resource>; +} + +/** What a plan or seat change did. */ +export type ChangeResult = + | { type: "changed"; subscription: Subscription } + /** Payment comes first: send the customer to url. Nothing changed yet. */ + | { type: "checkout"; url: string }; + +export interface ChangeOptions { + seats?: number; + /** Where the payment page sends the customer back to. */ + returnUrl?: string; + /** Where it sends them if they back out. */ + cancelUrl?: string; +} + +export interface SubscriptionApi { + /** + * The tenant this API is pinned to, or null for the session's own (its + * active organization, or the user in user mode). + */ + readonly tenantId: string | null; + /** + * The same API for another tenant, with its own cache. For operators who + * hold billing:manage and look after customers' billing; the server + * refuses anyone else. Calling it twice with one ID returns one instance. + */ + forTenant(tenantId: string): SubscriptionApi; + getSnapshot(): SubscriptionSnapshot; + /** Listen for snapshot changes. Returns a function that stops listening. */ + onChange(listener: () => void): () => void; + /** Loads the subscription and its entitlements. */ + load(): Promise; + /** Loads the plans the tenant can buy. */ + loadPlans(): Promise; + /** Starts a subscription on a plan, by slug or ID. */ + subscribe(plan: string, options?: ChangeOptions): Promise; + /** Moves the subscription to another plan, by slug or ID. */ + changePlan(plan: string, options?: ChangeOptions): Promise; + /** Changes the seat count on the current plan. */ + setSeats(seats: number, options?: Omit): Promise; + /** Cancels at the end of the period (the default) or now. */ + cancel(options?: { when?: "period_end" | "now" }): Promise; + /** Takes back a cancellation scheduled for the end of the period. */ + resume(): Promise; + /** A link to the payment provider's billing portal. */ + openPortal(returnUrl?: string): Promise; + /** The cached check for a feature, null when it isn't on the plan or not loaded. */ + entitlement(feature: string): Entitlement | null; +} + +const idle = (data: T): Resource => ({ status: "idle", data, error: null }); + +function initialSnapshot(): SubscriptionSnapshot { + return { + subscription: idle(null), + canManage: false, + plans: idle([]), + entitlements: idle>({}), + }; +} + +function asError(err: unknown): Error { + return err instanceof Error ? err : new Error(String(err)); +} + +// State every scope of one plugin instance shares. +interface Shared { + scopes: Map; + warned: boolean; +} + +const UNAVAILABLE_WARNING = + "authsome: the server doesn't have the subscription plugin, so billing reads as " + + "unavailable and every entitlement check fails closed. Register it on the server with " + + "authsome.WithPlugin(subscription.New(...)), or drop subscription() from your client plugins."; + +function isProduction(): boolean { + const env = (globalThis as { process?: { env?: Record } }).process?.env; + return env?.NODE_ENV === "production"; +} + +/** Builds the subscription API over a plugin context. */ +export function createSubscriptionApi(ctx: PluginContext): SubscriptionApi { + return createScope(ctx, null, { scopes: new Map(), warned: false }); +} + +function createScope(ctx: PluginContext, tenantId: string | null, shared: Shared): SubscriptionApi { + // A pinned scope names its tenant on every request; the session's scope + // leaves it to the server. + const scoped: Record = tenantId ? { tenant_id: tenantId } : {}; + let snapshot = initialSnapshot(); + const listeners = new Set<() => void>(); + // Bumped whenever the session or organization changes. Anything that + // started before the bump (a load, a plans fetch, a write) finds a + // different number when it lands, and leaves the cache alone, so a + // response for the organization or user the session just left is never + // shown as the current one. + let generation = 0; + // In-flight loads, shared by every caller in the same generation, so ten + // components mounting at once make one pair of requests. + let loading: { gen: number; promise: Promise } | null = null; + let loadingPlans: { gen: number; promise: Promise } | null = null; + + const set = (patch: Partial) => { + snapshot = { ...snapshot, ...patch }; + for (const listener of listeners) { + try { + listener(); + } catch { + // A listener's error mustn't stop the others. + } + } + }; + + const unavailable = () => { + if (ctx.isServerEnabled() !== false) return false; + if (!shared.warned && !isProduction()) { + shared.warned = true; + console.warn(UNAVAILABLE_WARNING); + } + return true; + }; + const signedIn = () => ctx.getState().status === "authenticated"; + + const loadEntitlements = async (gen: number) => { + set({ entitlements: { ...snapshot.entitlements, status: "loading", error: null } }); + try { + const res = await ctx.request<{ entitlements: Record }>( + "GET", + "/v1/billing/entitlements", + { query: scoped }, + ); + if (gen !== generation) return; + const entitlements: Record = {}; + for (const [key, value] of Object.entries(res.entitlements ?? {})) { + entitlements[key] = toEntitlement(value); + } + set({ entitlements: { status: "ready", data: entitlements, error: null } }); + } catch (err) { + if (gen !== generation) return; + set({ entitlements: { ...snapshot.entitlements, status: "error", error: asError(err) } }); + } + }; + + const fetchSubscription = async (gen: number) => { + set({ subscription: { ...snapshot.subscription, status: "loading", error: null } }); + const entitlements = loadEntitlements(gen); + try { + const res = await ctx.request<{ subscription: SubscriptionJSON | null; can_manage: boolean }>( + "GET", + "/v1/billing/subscription", + { query: scoped }, + ); + if (gen !== generation) return; + set({ + subscription: { + status: "ready", + data: res.subscription ? toSubscription(res.subscription) : null, + error: null, + }, + canManage: res.can_manage, + }); + } catch (err) { + if (gen !== generation) return; + set({ subscription: { ...snapshot.subscription, status: "error", error: asError(err) } }); + } + await entitlements; + }; + + const load = (): Promise => { + if (unavailable()) { + set({ + subscription: { status: "unavailable", data: null, error: null }, + entitlements: { status: "unavailable", data: {}, error: null }, + }); + return Promise.resolve(); + } + if (!signedIn()) return Promise.resolve(); + if (loading && loading.gen === generation) return loading.promise; + const gen = generation; + const promise = fetchSubscription(gen).finally(() => { + if (loading?.promise === promise) loading = null; + }); + loading = { gen, promise }; + return promise; + }; + + const fetchPlans = async (gen: number) => { + // /plans is app-wide and wants the app's ID: the client config carries + // it, and so does the signed-in user. + const state = ctx.getState(); + const appId = + ctx.getClientConfig()?.app_id ?? + (state.status === "authenticated" ? state.user.app_id : undefined); + set({ plans: { ...snapshot.plans, status: "loading", error: null } }); + try { + const res = await ctx.request<{ plans: PlanJSON[] }>("GET", "/v1/billing/plans", { + query: { app_id: appId }, + }); + if (gen !== generation) return; + set({ plans: { status: "ready", data: (res.plans ?? []).map(toPlan), error: null } }); + } catch (err) { + if (gen !== generation) return; + set({ plans: { ...snapshot.plans, status: "error", error: asError(err) } }); + } + }; + + const loadPlans = (): Promise => { + if (unavailable()) { + set({ plans: { status: "unavailable", data: [], error: null } }); + return Promise.resolve(); + } + if (loadingPlans && loadingPlans.gen === generation) return loadingPlans.promise; + const gen = generation; + const promise = fetchPlans(gen).finally(() => { + if (loadingPlans?.promise === promise) loadingPlans = null; + }); + loadingPlans = { gen, promise }; + return promise; + }; + + // Writes answer with the new subscription. It goes into the cache, and the + // entitlements are rechecked since the plan may have moved, unless the + // session changed while the write was out: then the caller still gets the + // result, but the cache now belongs to someone else. + const commit = (json: SubscriptionJSON | undefined, gen: number): Subscription | null => { + if (!json) return null; + const sub = toSubscription(json); + if (gen === generation) { + set({ subscription: { status: "ready", data: sub, error: null } }); + void loadEntitlements(gen); + } + return sub; + }; + + const change = async ( + method: "POST" | "PATCH", + body: Record, + ): Promise => { + const gen = generation; + const res = await ctx.request<{ subscription?: SubscriptionJSON; checkout_url?: string }>( + method, + "/v1/billing/subscription", + { body: { ...body, ...scoped } }, + ); + if (res.checkout_url) return { type: "checkout", url: res.checkout_url }; + const sub = commit(res.subscription, gen); + if (!sub) throw new Error("authsome: the server answered with neither a subscription nor a checkout URL"); + return { type: "changed", subscription: sub }; + }; + + const changeBody = (options: ChangeOptions | undefined, extra: Record) => ({ + ...extra, + ...(options?.seats !== undefined ? { seats: options.seats } : {}), + ...(options?.returnUrl ? { return_url: options.returnUrl } : {}), + ...(options?.cancelUrl ? { cancel_url: options.cancelUrl } : {}), + }); + + const mutate = async (path: string, body: Record): Promise => { + const gen = generation; + const res = await ctx.request<{ subscription?: SubscriptionJSON }>("POST", path, { + body: { ...body, ...scoped }, + }); + const sub = commit(res.subscription, gen); + if (!sub) throw new Error("authsome: the server answered without a subscription"); + return sub; + }; + + // Follow the session: a sign-out forgets everything; a sign-in or an + // organization switch drops the previous tenant's subscription at once + // (so no screen shows it, or offers buttons for it, while the new one + // loads) and reloads whatever had been loaded. A pinned tenant doesn't + // change with the organization, but can_manage does change with the user. + ctx.on("signedOut", () => { + generation++; + snapshot = initialSnapshot(); + set({}); + }); + const reload = () => { + generation++; + if (snapshot.subscription.status === "idle") return; + set({ + subscription: { status: "loading", data: null, error: null }, + entitlements: { status: "loading", data: {}, error: null }, + canManage: false, + }); + void load(); + }; + ctx.on("signedIn", reload); + if (!tenantId) ctx.on("orgChanged", reload); + + return { + tenantId, + forTenant(id) { + let scope = shared.scopes.get(id); + if (!scope) { + scope = createScope(ctx, id, shared); + shared.scopes.set(id, scope); + } + return scope; + }, + getSnapshot: () => snapshot, + onChange(listener) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + load, + loadPlans, + subscribe: (plan, options) => change("POST", changeBody(options, { plan })), + changePlan: (plan, options) => change("PATCH", changeBody(options, { plan })), + setSeats: (seats, options) => change("PATCH", changeBody({ ...options, seats }, {})), + cancel: (options) => mutate("/v1/billing/subscription/cancel", { when: options?.when ?? "period_end" }), + resume: () => mutate("/v1/billing/subscription/resume", {}), + async openPortal(returnUrl) { + const res = await ctx.request<{ url: string }>("POST", "/v1/billing/portal", { + body: { ...(returnUrl ? { return_url: returnUrl } : {}), ...scoped }, + }); + return res.url; + }, + entitlement: (feature) => snapshot.entitlements.data[feature] ?? null, + }; +} + +/** + * The subscription client plugin. Register it with `plugins: [subscription()]` + * and read it with `manager.plugin(subscription)` or, in React, + * `useSubscription()`. + */ +export const subscription = definePlugin("subscription", (ctx) => createSubscriptionApi(ctx)); diff --git a/ui/packages/plugin-subscription/src/react.test.tsx b/ui/packages/plugin-subscription/src/react.test.tsx new file mode 100644 index 00000000..561f7e98 --- /dev/null +++ b/ui/packages/plugin-subscription/src/react.test.tsx @@ -0,0 +1,172 @@ +import { act, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { Entitled, usePlans, useSubscription } from "./react"; +import { harness, json, planJSON, subJSON } from "./test-support"; + +describe("useSubscription", () => { + it("reads another tenant when given a tenantId", async () => { + const h = harness(); + let seen = null as unknown as ReturnType; + function Probe() { + seen = useSubscription({ tenantId: "org_9" }); + return null; + } + await h.mount(); + await waitFor(() => expect(seen.subscription?.id).toBe("sub_1")); + expect(h.urls).toContain("GET /v1/billing/subscription?tenant_id=org_9"); + expect(h.urls).not.toContain("GET /v1/billing/subscription"); + }); + + it("loads on mount and re-renders after a plan change", async () => { + const h = harness(); + h.routes.set("PATCH /v1/billing/subscription", () => json({ subscription: subJSON({ plan: planJSON("team") }) })); + let seen = null as unknown as ReturnType; + function Probe() { + seen = useSubscription(); + return {seen.isLoading ? "loading" : seen.plan?.name ?? "none"}; + } + await h.mount(); + await waitFor(() => expect(screen.getByTestId("plan").textContent).toBe("Pro")); + expect(seen.canManage).toBe(true); + + await act(async () => { + const result = await seen.changePlan("team"); + expect(result.type).toBe("changed"); + }); + expect(screen.getByTestId("plan").textContent).toBe("Team"); + expect(seen.isPending).toBe(false); + }); + + it("reports pending while a write is in flight", async () => { + const h = harness(); + let release!: () => void; + const gate = new Promise((r) => (release = r)); + h.routes.set("POST /v1/billing/subscription/cancel", async () => { + await gate; + return json({ subscription: subJSON({ cancel_at: "2026-12-01T00:00:00Z" }) }); + }); + let seen = null as unknown as ReturnType; + function Probe() { + seen = useSubscription(); + return null; + } + await h.mount(); + await waitFor(() => expect(seen.subscription).not.toBeNull()); + + let canceling!: Promise; + act(() => { + canceling = seen.cancel(); + }); + await waitFor(() => expect(seen.isPending).toBe(true)); + await act(async () => { + release(); + await canceling; + }); + expect(seen.isPending).toBe(false); + expect(seen.subscription?.willCancel).toBe(true); + }); +}); + +describe("sharing", () => { + it("makes one request pair however many components read the subscription", async () => { + const h = harness(); + function Probe() { + useSubscription(); + return null; + } + await h.mount( + <> + + + + + , + ); + await waitFor(() => expect(h.calls.filter((c) => c === "GET /v1/billing/subscription")).toHaveLength(1)); + expect(h.calls.filter((c) => c === "GET /v1/billing/entitlements")).toHaveLength(1); + }); + + it("keeps the write functions stable between renders", async () => { + const h = harness(); + const seen: ReturnType[] = []; + function Probe() { + seen.push(useSubscription()); + return null; + } + await h.mount(); + await waitFor(() => expect(seen.at(-1)?.subscription).not.toBeNull()); + const first = seen[0]; + const last = seen.at(-1)!; + expect(last.changePlan).toBe(first.changePlan); + expect(last.cancel).toBe(first.cancel); + expect(last.refresh).toBe(first.refresh); + }); +}); + +describe("usePlans", () => { + it("loads the plans on mount", async () => { + let seen = null as unknown as ReturnType; + function Probe() { + seen = usePlans(); + return null; + } + await harness().mount(); + await waitFor(() => expect(seen.plans.map((p) => p.slug)).toEqual(["free", "pro", "team"])); + expect(seen.isLoading).toBe(false); + }); +}); + +describe("Entitled", () => { + it("shows the loading slot, then the children when the plan allows it", async () => { + const h = harness(); + let release!: () => void; + const gate = new Promise((r) => (release = r)); + const original = h.routes.get("GET /v1/billing/entitlements")!; + h.routes.set("GET /v1/billing/entitlements", async () => { + await gate; + return original(); + }); + await h.mount( + upgrade} loading={checking}> + sso-settings + , + ); + expect(screen.queryByText("checking")).not.toBeNull(); + expect(screen.queryByText("sso-settings")).toBeNull(); + await act(async () => release()); + await waitFor(() => expect(screen.queryByText("sso-settings")).not.toBeNull()); + }); + + it("falls back for a feature the plan doesn't have", async () => { + await harness().mount( + upgrade}> + audit + , + ); + await waitFor(() => expect(screen.queryByText("upgrade")).not.toBeNull()); + expect(screen.queryByText("audit")).toBeNull(); + }); + + it("falls back when the server has no subscription plugin", async () => { + const h = harness({ serverPlugins: ["organization"] }); + await h.mount( + upgrade}> + sso-settings + , + ); + await waitFor(() => expect(screen.queryByText("upgrade")).not.toBeNull()); + expect(h.calls.some((c) => c.includes("/v1/billing"))).toBe(false); + }); + + it("falls back on an error", async () => { + const h = harness(); + h.routes.set("GET /v1/billing/entitlements", () => json({ error: "down" }, 500)); + await h.mount( + upgrade}> + sso-settings + , + ); + await waitFor(() => expect(screen.queryByText("upgrade")).not.toBeNull()); + }); +}); diff --git a/ui/packages/plugin-subscription/src/react.tsx b/ui/packages/plugin-subscription/src/react.tsx new file mode 100644 index 00000000..4912e8b6 --- /dev/null +++ b/ui/packages/plugin-subscription/src/react.tsx @@ -0,0 +1,197 @@ +/** + * React bindings for the subscription client plugin. + * + * ```tsx + * ... + * + * const { plan, subscription, changePlan } = useSubscription(); + * }> + * ``` + */ + +import { useAuth, usePlugin } from "@authsome/ui-react"; +import { + useCallback, + useEffect, + useMemo, + useState, + useSyncExternalStore, + type ReactNode, +} from "react"; + +import type { Entitlement, Plan, Subscription } from "./models"; +import { + subscription, + type ChangeOptions, + type ChangeResult, + type Resource, + type SubscriptionApi, + type SubscriptionSnapshot, +} from "./plugin"; + +/** + * Which tenant a hook or component is about. Leave it out for the session's + * own; operators holding billing:manage can pass a customer's. + */ +export interface TenantOptions { + tenantId?: string; +} + +/** The plugin's API, for anything the hooks don't cover. */ +export function useSubscriptionApi(options?: TenantOptions): SubscriptionApi { + const api = usePlugin(subscription); + // forTenant hands back one instance per ID, so this is stable. + return options?.tenantId ? api.forTenant(options.tenantId) : api; +} + +function useSnapshot(api: SubscriptionApi): SubscriptionSnapshot { + return useSyncExternalStore(api.onChange, api.getSnapshot, api.getSnapshot); +} + +/** Loads a resource once the user is signed in and it hasn't been loaded. */ +function useAutoLoad(status: Resource["status"], load: () => Promise) { + const { isAuthenticated } = useAuth(); + useEffect(() => { + if (isAuthenticated && status === "idle") void load(); + }, [isAuthenticated, status, load]); + return isAuthenticated; +} + +const isPendingStatus = (status: Resource["status"], signedIn: boolean) => + status === "loading" || (status === "idle" && signedIn); + +/** Counts writes in flight, so components can disable buttons. */ +function usePendingWrites() { + const [pending, setPending] = useState(0); + const track = useCallback((work: () => Promise): Promise => { + setPending((n) => n + 1); + return work().finally(() => setPending((n) => n - 1)); + }, []); + return { isPending: pending > 0, track }; +} + +export interface UseSubscriptionResult { + /** The tenant's subscription, null when it has none (or before it loads). */ + subscription: Subscription | null; + /** The subscription's plan. */ + plan: Plan | null; + /** Whether you may change it. Hide upgrade buttons when false. */ + canManage: boolean; + status: Resource["status"]; + isLoading: boolean; + error: Error | null; + refresh: () => Promise; + subscribe: (plan: string, options?: ChangeOptions) => Promise; + changePlan: (plan: string, options?: ChangeOptions) => Promise; + setSeats: (seats: number, options?: Omit) => Promise; + cancel: (options?: { when?: "period_end" | "now" }) => Promise; + resume: () => Promise; + openPortal: (returnUrl?: string) => Promise; + /** A write is in flight. */ + isPending: boolean; +} + +/** + * The tenant's subscription and everything you can do to it. Loads on first + * use; writes update every component reading it. + */ +export function useSubscription(options?: TenantOptions): UseSubscriptionResult { + const api = useSubscriptionApi(options); + const snap = useSnapshot(api); + const signedIn = useAutoLoad(snap.subscription.status, api.load); + const { isPending, track } = usePendingWrites(); + + // Stable across renders, so they're safe in effect and memo dependencies. + const writes = useMemo( + () => ({ + subscribe: (plan: string, options?: ChangeOptions) => track(() => api.subscribe(plan, options)), + changePlan: (plan: string, options?: ChangeOptions) => track(() => api.changePlan(plan, options)), + setSeats: (seats: number, options?: Omit) => + track(() => api.setSeats(seats, options)), + cancel: (options?: { when?: "period_end" | "now" }) => track(() => api.cancel(options)), + resume: () => track(() => api.resume()), + openPortal: (returnUrl?: string) => track(() => api.openPortal(returnUrl)), + }), + [api, track], + ); + + return { + subscription: snap.subscription.data, + plan: snap.subscription.data?.plan ?? null, + canManage: snap.canManage, + status: snap.subscription.status, + isLoading: isPendingStatus(snap.subscription.status, signedIn), + error: snap.subscription.error, + refresh: api.load, + ...writes, + isPending, + }; +} + +/** The plans the tenant can buy, cheapest first. Loads on first use. */ +export function usePlans(): { + plans: Plan[]; + status: Resource["status"]; + isLoading: boolean; + error: Error | null; + refresh: () => Promise; +} { + const api = useSubscriptionApi(); + const snap = useSnapshot(api); + const signedIn = useAutoLoad(snap.plans.status, api.loadPlans); + return { + plans: snap.plans.data, + status: snap.plans.status, + isLoading: isPendingStatus(snap.plans.status, signedIn), + error: snap.plans.error, + refresh: api.loadPlans, + }; +} + +/** + * Whether the plan includes a feature. `allowed` stays false until the check + * has loaded, and on an error, so gating on it fails closed. + */ +export function useEntitlement(feature: string, options?: TenantOptions): { + allowed: boolean; + entitlement: Entitlement | null; + status: Resource["status"]; + isLoading: boolean; +} { + const api = useSubscriptionApi(options); + const snap = useSnapshot(api); + // Entitlements load with the subscription. + const signedIn = useAutoLoad(snap.subscription.status, api.load); + const entitlement = snap.entitlements.data[feature] ?? null; + return { + allowed: snap.entitlements.status === "ready" && entitlement?.allowed === true, + entitlement, + status: snap.entitlements.status, + isLoading: isPendingStatus(snap.entitlements.status, signedIn), + }; +} + +export interface EntitledProps extends TenantOptions { + /** The plan feature to check, e.g. "sso". */ + feature: string; + children: ReactNode; + /** Shown when the plan doesn't include the feature, on an error, or when billing isn't installed. */ + fallback?: ReactNode; + /** Shown while the check loads. Defaults to the fallback. */ + loading?: ReactNode; +} + +/** + * Shows its children only when the plan includes the feature. Anything short + * of a positive answer (loading, an error, billing not installed) shows the + * fallback, so a paywall never opens by accident. + */ +export function Entitled({ feature, children, fallback = null, loading, tenantId }: EntitledProps) { + const { allowed, isLoading } = useEntitlement(feature, { tenantId }); + if (allowed) return <>{children}; + if (isLoading) return <>{loading ?? fallback}; + return <>{fallback}; +} + +export { subscription }; +export type { ChangeOptions, ChangeResult, Resource, SubscriptionApi, SubscriptionSnapshot }; diff --git a/ui/packages/plugin-subscription/src/test-support.tsx b/ui/packages/plugin-subscription/src/test-support.tsx new file mode 100644 index 00000000..460a7876 --- /dev/null +++ b/ui/packages/plugin-subscription/src/test-support.tsx @@ -0,0 +1,89 @@ +import type { ClientConfig } from "@authsome/ui-core"; +import { AuthProvider } from "@authsome/ui-react"; +import { act, render } from "@testing-library/react"; +import type { ReactNode } from "react"; + +import { subscription } from "./plugin"; + +export const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }); + +export function planJSON(slug: string, overrides: Record = {}) { + return { + id: `plan_${slug}`, name: slug[0].toUpperCase() + slug.slice(1), slug, description: "", + currency: "usd", status: "active", trial_days: 0, features_count: 1, is_addon: false, app_id: "app_1", + base_amount: "29.00", billing_period: "monthly", amount_minor: 2900, + features: [{ key: "sso", name: "SSO", type: "boolean", limit: 1, soft_limit: false }], + ...overrides, + }; +} + +export function subJSON(overrides: Record = {}) { + return { + id: "sub_1", tenant_id: "org_1", status: "active", plan: planJSON("pro"), + current_period_start: "2026-11-01T00:00:00Z", current_period_end: "2026-12-01T00:00:00Z", + trial_start: null, trial_end: null, cancel_at: null, canceled_at: null, paused_at: null, + app_id: "app_1", + ...overrides, + }; +} + +export type Handler = (init?: RequestInit) => Response | Promise; + +/** + * A signed-in AuthProvider with the subscription plugin, over a routed + * fetch. Routes default to an active "pro" subscription the user can manage. + */ +export function harness(opts: { serverPlugins?: string[] } = {}) { + const store = new Map([[ + "authsome:session", + JSON.stringify({ session_token: "tok", refresh_token: "ref", expires_at: new Date(Date.now() + 3_600_000).toISOString() }), + ]]); + const routes = new Map([ + ["GET /v1/me", () => json({ id: "usr_1", email: "a@test", app_id: "app_1", active_org_id: "org_1" })], + ["GET /v1/billing/subscription", () => json({ subscription: subJSON(), can_manage: true })], + ["GET /v1/billing/entitlements", () => json({ entitlements: { sso: { allowed: true, feature: "sso", used: 2, limit: 10, remaining: 8 } } })], + ["GET /v1/billing/plans", () => json({ plans: [planJSON("free", { amount_minor: undefined, base_amount: undefined }), planJSON("pro"), planJSON("team", { amount_minor: 9900, base_amount: "99.00" })] })], + ]); + const calls: string[] = []; + // The same calls with their query strings, for tests that care. + const urls: string[] = []; + const fetchFn = (async (input: string | URL, init?: RequestInit) => { + const url = new URL(String(input)); + const key = `${init?.method ?? "GET"} ${url.pathname}`; + calls.push(key); + urls.push(`${key}${url.search}`); + const handler = routes.get(key); + return handler ? handler(init) : json({ error: `no route ${key}` }, 404); + }) as unknown as typeof fetch; + const initialClientConfig = opts.serverPlugins + ? ({ supported_plugins: opts.serverPlugins } as unknown as ClientConfig) + : undefined; + + const wrap = (children: ReactNode) => ( + store.get(k) ?? null, + setItem: (k, v) => void store.set(k, v), + removeItem: (k) => void store.delete(k), + }} + > + {children} + + ); + + return { + routes, + calls, + urls, + async mount(node: ReactNode): Promise { + await act(async () => { + render(wrap(node)); + }); + }, + }; +} diff --git a/ui/packages/plugin-subscription/tsconfig.json b/ui/packages/plugin-subscription/tsconfig.json new file mode 100644 index 00000000..eb3621e3 --- /dev/null +++ b/ui/packages/plugin-subscription/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "jsx": "react-jsx", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/ui/packages/plugin-subscription/tsup.config.ts b/ui/packages/plugin-subscription/tsup.config.ts new file mode 100644 index 00000000..cb842c3c --- /dev/null +++ b/ui/packages/plugin-subscription/tsup.config.ts @@ -0,0 +1,30 @@ +import { defineConfig, type Options } from "tsup"; + +// Three entries so an app pays only for what it imports: the framework-free +// plugin, the React hooks, and the styled components built on +// @authsome/ui-components. Only the React entries are marked "use client": +// the plugin and its models stay importable from server components. The two +// builds run side by side, so neither cleans dist; the build script does. +const shared: Options = { + format: ["esm"], + dts: true, + clean: false, + sourcemap: true, + target: "es2022", + external: [ + "react", + "react-dom", + "@authsome/ui-core", + "@authsome/ui-react", + "@authsome/ui-components", + ], +}; + +export default defineConfig([ + { ...shared, entry: ["src/index.ts"] }, + { + ...shared, + entry: ["src/react.tsx", "src/components.tsx"], + banner: { js: '"use client";' }, + }, +]); diff --git a/ui/packages/plugin-subscription/vitest.config.ts b/ui/packages/plugin-subscription/vitest.config.ts new file mode 100644 index 00000000..2fcc35bd --- /dev/null +++ b/ui/packages/plugin-subscription/vitest.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from "vitest/config"; + +// ui-core and ui-nextjs get by on vitest's defaults because they test plain +// modules. This package renders components, so it needs a DOM. +// +// globals is on so @testing-library/react can register its own afterEach +// cleanup. Without it every render stays in the document and the next test +// finds several copies of the same element. Tests still import describe/it/ +// expect explicitly, the same way the other packages do. +export default defineConfig({ + test: { + environment: "jsdom", + globals: true, + include: ["src/**/*.test.{ts,tsx}"], + }, +}); diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index 541316a9..e0140950 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -257,6 +257,49 @@ importers: specifier: ^4.1.12 version: 4.1.12(@types/node@26.3.0)(jsdom@28.1.0)(vite@8.3.5(@types/node@26.3.0)(esbuild@0.28.2)(jiti@2.7.0)) + packages/plugin-subscription: + dependencies: + '@authsome/ui-core': + specifier: workspace:* + version: link:../core + devDependencies: + '@authsome/ui-components': + specifier: workspace:* + version: link:../components + '@authsome/ui-react': + specifier: workspace:* + version: link:../react + '@testing-library/react': + specifier: ^16.3.0 + version: 16.3.2(@testing-library/dom@10.4.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@types/react': + specifier: ^19.2.18 + version: 19.2.18 + '@types/react-dom': + specifier: ^19.2.5 + version: 19.2.5(@types/react@19.2.18) + jsdom: + specifier: ^28.0.1 + version: 28.1.0 + react: + specifier: ^19.2.8 + version: 19.2.8 + react-dom: + specifier: ^19.2.8 + version: 19.2.8(react@19.2.8) + tsup: + specifier: ^8.5.1 + version: 8.5.1(jiti@2.7.0)(postcss@8.5.26)(typescript@5.9.3) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vite: + specifier: ^8.2.4 + version: 8.3.5(@types/node@26.3.0)(esbuild@0.28.2)(jiti@2.7.0) + vitest: + specifier: ^4.1.12 + version: 4.1.12(@types/node@26.3.0)(jsdom@28.1.0)(vite@8.3.5(@types/node@26.3.0)(esbuild@0.28.2)(jiti@2.7.0)) + packages/react: dependencies: '@authsome/ui-core':