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/docs/content/docs/plugins/subscription.mdx b/docs/content/docs/plugins/subscription.mdx index 46af8af9..bcaea046 100644 --- a/docs/content/docs/plugins/subscription.mdx +++ b/docs/content/docs/plugins/subscription.mdx @@ -147,6 +147,58 @@ 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`. + +The billing methods on the core `ui-core` and `authsome_core` clients still work, and are marked deprecated in favour of these packages. + ## 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 04d280da..9bf39d65 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..5bf75f99 --- /dev/null +++ b/flutter/packages/authsome_subscription/README.md @@ -0,0 +1,45 @@ +# 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. 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..bd43f9ad --- /dev/null +++ b/flutter/packages/authsome_subscription/lib/src/subscription_api.dart @@ -0,0 +1,331 @@ +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 } + +/// The subscription plugin's API, from `auth.plugin()` or, +/// in Flutter, `context.subscription`. +class SubscriptionApi { + SubscriptionApi(this._ctx) { + // 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 || AuthEvent.orgChanged: + _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; + 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); + } + + bool get _unavailable => _ctx.isServerEnabled == false; + 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') 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') 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: {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: 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: 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..5ea4c07f --- /dev/null +++ b/flutter/packages/authsome_subscription/test/subscription_api_test.dart @@ -0,0 +1,387 @@ +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); + }); + }); +} 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..473f49c3 --- /dev/null +++ b/flutter/packages/authsome_subscription_flutter/README.md @@ -0,0 +1,60 @@ +# 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. + +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..b9b69ae9 --- /dev/null +++ b/flutter/packages/authsome_subscription_flutter/lib/src/subscription_widgets.dart @@ -0,0 +1,432 @@ +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(); +} + +/// 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. +class SubscriptionBuilder extends StatefulWidget { + const SubscriptionBuilder({required this.builder, this.loadPlans = false, super.key}); + + final Widget Function(BuildContext context, SubscriptionSnapshot snapshot) builder; + final bool loadPlans; + + @override + State createState() => _SubscriptionBuilderState(); +} + +class _SubscriptionBuilderState extends State { + SubscriptionApi? _api; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final auth = AuthProvider.of(context); + final api = auth.plugin(); + _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, + super.key, + }); + + final String feature; + final Widget child; + final Widget fallback; + final Widget? loading; + + @override + Widget build(BuildContext context) { + return SubscriptionBuilder(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, + super.key, + }); + + final ValueChanged? onSelected; + + /// 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, + 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.subscription, 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, super.key}); + + /// 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); + } + } + + 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(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.subscription; + 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 : () => _run(api.cancel), + 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, super.key}); + + final String feature; + + /// What to call it. Defaults to the feature key. + final String? label; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return SubscriptionBuilder(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, super.key}); + + final ValueChanged onOpen; + 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(builder: (context, snap) { + if (!snap.canManage) return const SizedBox.shrink(); + return OutlinedButton( + onPressed: _busy ? null : () => _open(context.subscription), + 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..e7a86770 --- /dev/null +++ b/flutter/packages/authsome_subscription_flutter/test/widgets_test.dart @@ -0,0 +1,279 @@ +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 = []; + + 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); + 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 _settle(tester); + expect(find.text("Couldn't update your plan. Try again."), findsOneWidget); + expect(reported, isNotNull); + }); + + 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..b868c478 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. Notes can't contain a semicolon, and the Dart ones no +# quotes, since they land in a quoted annotation. +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..bf1023a6 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.\")") if err := fs.Parse(os.Args[2:]); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) @@ -89,6 +90,15 @@ func main() { } } + // Parse deprecation rules + deprecated := make(map[string]string) + for _, rule := range strings.Split(*deprecateTags, ";") { + parts := strings.SplitN(strings.TrimSpace(rule), "=", 2) + if len(parts) == 2 && parts[0] != "" { + deprecated[parts[0]] = strings.TrimSpace(parts[1]) + } + } + // Load or generate OpenAPI spec. var spec *openapi.Spec if *fromSpec != "" { @@ -113,7 +123,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 +133,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 +146,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 +158,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 +174,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..cd328214 --- /dev/null +++ b/sdkgen/dart/deprecate_test.go @@ -0,0 +1,47 @@ +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") +} diff --git a/sdkgen/dart/generator.go b/sdkgen/dart/generator.go index fb178917..b74ce237 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 = 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..67d6d503 --- /dev/null +++ b/sdkgen/openapi/deprecate.go @@ -0,0 +1,33 @@ +package openapi + +import "strings" + +// 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..ddd52ff5 --- /dev/null +++ b/sdkgen/openapi/deprecate_test.go @@ -0,0 +1,24 @@ +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)) +} diff --git a/sdkgen/typescript/deprecate_test.go b/sdkgen/typescript/deprecate_test.go new file mode 100644 index 00000000..926a5d76 --- /dev/null +++ b/sdkgen/typescript/deprecate_test.go @@ -0,0 +1,48 @@ +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") +} diff --git a/sdkgen/typescript/generator.go b/sdkgen/typescript/generator.go index 09af7382..bea81f9e 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 = 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 3b6fa3f3..d08c6659 100644 --- a/ui/packages/core/src/generated/api-client.ts +++ b/ui/packages/core/src/generated/api-client.ts @@ -1456,6 +1456,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(); @@ -1473,6 +1474,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"; @@ -1487,6 +1489,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}`; @@ -1501,6 +1504,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(); @@ -1518,6 +1522,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(); @@ -1536,6 +1541,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(); @@ -1554,6 +1560,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}`; @@ -1568,6 +1575,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`; @@ -1582,6 +1590,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`; @@ -1596,6 +1605,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(); @@ -1615,6 +1625,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"; @@ -1629,6 +1640,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}`; @@ -1643,6 +1655,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`; @@ -1657,6 +1670,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`; @@ -1671,6 +1685,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"; @@ -1685,6 +1700,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(); @@ -1702,6 +1718,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"; @@ -1716,6 +1733,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"; @@ -1730,6 +1748,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"; @@ -1744,6 +1763,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"; @@ -1758,6 +1778,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(); @@ -1777,6 +1798,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"; @@ -1791,6 +1813,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(); @@ -1809,6 +1832,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`; @@ -1823,6 +1847,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`; @@ -1837,6 +1862,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`; @@ -1851,6 +1877,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`; @@ -1865,6 +1892,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..ccd4ba73 --- /dev/null +++ b/ui/packages/plugin-subscription/README.md @@ -0,0 +1,89 @@ +# @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"; +``` + +## 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. 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..a3cfb950 --- /dev/null +++ b/ui/packages/plugin-subscription/src/components.test.tsx @@ -0,0 +1,152 @@ +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("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..384cbe7a --- /dev/null +++ b/ui/packages/plugin-subscription/src/components.tsx @@ -0,0 +1,354 @@ +/** + * 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 } 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 { + /** 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, +}: PricingTableProps) { + const { plans, isLoading, error } = usePlans(); + const { subscription, canManage, subscribe, changePlan, isPending } = useSubscription(); + 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 { + /** 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 }: CurrentPlanCardProps) { + const { subscription: sub, canManage, isLoading, error, cancel, resume, isPending } = + useSubscription(); + const [failed, setFailed] = useState(null); + + const run = async (write: () => Promise) => { + 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 ? ( + + ) : ( + + )} + + )} +
+ ); +} + +// ── UsageMeter ──────────────────────────────────── + +export interface UsageMeterProps { + /** 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 }: UsageMeterProps) { + const { entitlement, isLoading } = useEntitlement(feature); + 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 { + /** 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, +}: ManageBillingButtonProps) { + const { canManage, openPortal, isPending } = useSubscription(); + 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..433b7907 --- /dev/null +++ b/ui/packages/plugin-subscription/src/plugin.test.ts @@ -0,0 +1,326 @@ +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(); + }); +}); diff --git a/ui/packages/plugin-subscription/src/plugin.ts b/ui/packages/plugin-subscription/src/plugin.ts new file mode 100644 index 00000000..cd87ddf9 --- /dev/null +++ b/ui/packages/plugin-subscription/src/plugin.ts @@ -0,0 +1,311 @@ +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 { + 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)); +} + +/** Builds the subscription API over a plugin context. */ +export function createSubscriptionApi(ctx: PluginContext): SubscriptionApi { + 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 = () => ctx.isServerEnabled() === false; + 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", + ); + 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", + ); + 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 }, + ); + 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: unknown): Promise => { + const gen = generation; + const res = await ctx.request<{ subscription?: SubscriptionJSON }>("POST", path, { body }); + 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. + 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); + ctx.on("orgChanged", reload); + + return { + 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 } : {}, + }); + 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..d93ddf43 --- /dev/null +++ b/ui/packages/plugin-subscription/src/react.test.tsx @@ -0,0 +1,159 @@ +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("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..4ece143e --- /dev/null +++ b/ui/packages/plugin-subscription/src/react.tsx @@ -0,0 +1,187 @@ +/** + * 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"; + +/** The plugin's API, for anything the hooks don't cover. */ +export function useSubscriptionApi(): SubscriptionApi { + return usePlugin(subscription); +} + +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(): UseSubscriptionResult { + const api = useSubscriptionApi(); + 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): { + allowed: boolean; + entitlement: Entitlement | null; + status: Resource["status"]; + isLoading: boolean; +} { + const api = useSubscriptionApi(); + 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 { + /** 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 }: EntitledProps) { + const { allowed, isLoading } = useEntitlement(feature); + 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..065b66f5 --- /dev/null +++ b/ui/packages/plugin-subscription/src/test-support.tsx @@ -0,0 +1,85 @@ +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[] = []; + const fetchFn = (async (input: string | URL, init?: RequestInit) => { + const url = new URL(String(input)); + const key = `${init?.method ?? "GET"} ${url.pathname}`; + calls.push(key); + 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, + 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':