diff --git a/docs/content/docs/plugins/subscription.mdx b/docs/content/docs/plugins/subscription.mdx index bcaea046..f437fa44 100644 --- a/docs/content/docs/plugins/subscription.mdx +++ b/docs/content/docs/plugins/subscription.mdx @@ -197,8 +197,26 @@ final result = await billing.changePlan('team'); The widgets hand checkout and portal URLs back to you (`onCheckout`, `onOpen`), so the packages don't depend on a URL launcher. On both platforms the components show plain words when a write fails and pass the server's error to `onError`. +`CurrentPlanCard` asks before it cancels and says when the plan ends. If the server doesn't have the subscription plugin, the client packages say so once in the console during development, and every entitlement check fails closed. + The billing methods on the core `ui-core` and `authsome_core` clients still work, and are marked deprecated in favour of these packages. +### Another tenant's billing + +Support staff who hold `billing:manage` can look after a customer's billing from the same components. Pass the customer's tenant and every read and write carries it as `tenant_id`: + +```tsx + +const { subscription } = useSubscription({ tenantId: customer.orgId }); +``` + +```dart +CurrentPlanCard(tenantId: customer.orgId); +final billing = context.subscriptionFor(customer.orgId); +``` + +Underneath that's `api.forTenant(id)`, which returns the same API with its own cache. It keeps its tenant when you switch organization and clears when you sign out. The server refuses anyone without `billing:manage`. + ## Operator routes Plans, coupons, invoices and the ID-based subscription routes (`/subscriptions/:subId/...`) diff --git a/flutter/packages/authsome_subscription/README.md b/flutter/packages/authsome_subscription/README.md index 5bf75f99..124a6dde 100644 --- a/flutter/packages/authsome_subscription/README.md +++ b/flutter/packages/authsome_subscription/README.md @@ -42,4 +42,6 @@ switch (result) { `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. +The snapshot follows the session: signing out clears it, and signing in or switching organization reloads it. When the server doesn't have the subscription plugin, the resources come back `ResourceStatus.unavailable` without a request, and debug builds print one warning saying so. + +`billing.forTenant(orgId)` returns the same API pinned to another tenant, with its own cache, for support staff who hold `billing:manage`. It keeps its tenant across organization switches and clears on sign-out. diff --git a/flutter/packages/authsome_subscription/lib/src/subscription_api.dart b/flutter/packages/authsome_subscription/lib/src/subscription_api.dart index bd43f9ad..7fd6d6fc 100644 --- a/flutter/packages/authsome_subscription/lib/src/subscription_api.dart +++ b/flutter/packages/authsome_subscription/lib/src/subscription_api.dart @@ -81,10 +81,23 @@ class CheckoutRequired extends ChangeResult { /// When a cancellation takes effect. enum CancelWhen { periodEnd, now } +// State every scope of one plugin instance shares. +class _Shared { + final scopes = {}; + bool warned = false; +} + +const _unavailableWarning = + "authsome: the server doesn't have the subscription plugin, so billing reads as " + 'unavailable and every entitlement check fails closed. Register it on the server with ' + 'authsome.WithPlugin(subscription.New(...)), or drop SubscriptionPlugin from your client plugins.'; + /// The subscription plugin's API, from `auth.plugin()` or, /// in Flutter, `context.subscription`. class SubscriptionApi { - SubscriptionApi(this._ctx) { + SubscriptionApi(PluginContext ctx) : this._scoped(ctx, null, _Shared()); + + SubscriptionApi._scoped(this._ctx, this.tenantId, this._shared) { // Follow the session: a sign-out forgets everything, and a sign-in or // an organization switch reloads whatever had been loaded. _events = _ctx.events.listen( @@ -93,8 +106,11 @@ class SubscriptionApi { case AuthEvent.signedOut: _generation++; _set(SubscriptionSnapshot.initial()); - case AuthEvent.signedIn || AuthEvent.orgChanged: + case AuthEvent.signedIn: _reload(); + // A pinned tenant doesn't change with the organization. + case AuthEvent.orgChanged: + if (tenantId == null) _reload(); case AuthEvent.sessionRefreshed: break; } @@ -106,6 +122,12 @@ class SubscriptionApi { } final PluginContext _ctx; + final _Shared _shared; + + /// The tenant this API is pinned to, or null for the session's own (its + /// active organization, or the user in user mode). + final String? tenantId; + late final StreamSubscription _events; final _changes = StreamController.broadcast(); SubscriptionSnapshot _snapshot = SubscriptionSnapshot.initial(); @@ -128,7 +150,31 @@ class SubscriptionApi { if (!_changes.isClosed) _changes.add(next); } - bool get _unavailable => _ctx.isServerEnabled == false; + /// The same API for another tenant, with its own cache. For operators who + /// hold billing:manage and look after customers' billing; the server + /// refuses anyone else. Calling it twice with one ID returns one instance. + SubscriptionApi forTenant(String tenantId) => + _shared.scopes.putIfAbsent(tenantId, () => SubscriptionApi._scoped(_ctx, tenantId, _shared)); + + // A pinned scope names its tenant on every request; the session's scope + // leaves it to the server. + Map? get _query => tenantId == null ? null : {'tenant_id': tenantId!}; + Map _scoped(Map body) => + tenantId == null ? body : {...body, 'tenant_id': tenantId}; + + bool get _unavailable { + if (_ctx.isServerEnabled != false) return false; + if (!_shared.warned) { + _shared.warned = true; + // Debug builds only: asserts are stripped from release builds. + assert(() { + // ignore: avoid_print + print(_unavailableWarning); + return true; + }()); + } + return true; + } bool get _signedIn => _ctx.state is AuthAuthenticated; // A new session or organization: whatever was shown belongs to the old @@ -171,7 +217,7 @@ class SubscriptionApi { )); final entitlements = _loadEntitlements(gen); try { - final res = await _ctx.request('GET', '/v1/billing/subscription') as Map; + final res = await _ctx.request('GET', '/v1/billing/subscription', query: _query) as Map; if (gen != _generation) return; final sub = res['subscription'] as Map?; _set(_snapshot.copyWith( @@ -196,7 +242,7 @@ class SubscriptionApi { entitlements: Resource(ResourceStatus.loading, _snapshot.entitlements.data), )); try { - final res = await _ctx.request('GET', '/v1/billing/entitlements') as Map; + final res = await _ctx.request('GET', '/v1/billing/entitlements', query: _query) as Map; if (gen != _generation) return; final raw = Map.from(res['entitlements'] as Map? ?? const {}); _set(_snapshot.copyWith( @@ -265,7 +311,7 @@ class SubscriptionApi { /// 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; + body: _scoped({if (returnUrl != null) 'return_url': returnUrl})) as Map; return Uri.parse(res['url'] as String); } @@ -281,7 +327,7 @@ class SubscriptionApi { Future _change(String method, Map body) async { final gen = _generation; - final res = await _ctx.request(method, '/v1/billing/subscription', body: body) as Map; + final res = await _ctx.request(method, '/v1/billing/subscription', body: _scoped(body)) as Map; final url = res['checkout_url'] as String?; if (url != null && url.isNotEmpty) return CheckoutRequired(Uri.parse(url)); return SubscriptionChanged(_commit(res, gen)); @@ -289,7 +335,7 @@ class SubscriptionApi { Future _mutate(String path, Map body) async { final gen = _generation; - final res = await _ctx.request('POST', path, body: body) as Map; + final res = await _ctx.request('POST', path, body: _scoped(body)) as Map; return _commit(res, gen); } diff --git a/flutter/packages/authsome_subscription/test/subscription_api_test.dart b/flutter/packages/authsome_subscription/test/subscription_api_test.dart index 5ea4c07f..a8c4b866 100644 --- a/flutter/packages/authsome_subscription/test/subscription_api_test.dart +++ b/flutter/packages/authsome_subscription/test/subscription_api_test.dart @@ -384,4 +384,72 @@ void main() { expect(seen, isNotEmpty); }); }); + + group('a server without the plugin', () { + Future> printed(Future Function() body) async { + final lines = []; + await runZoned(body, zoneSpecification: ZoneSpecification( + print: (self, parent, zone, line) => lines.add(line), + )); + return lines; + } + + test('warns once in debug, naming the fix', () async { + final lines = await printed(() async { + final s = _setup(serverPlugins: ['organization']); + await s.manager.initialize(); + await s.api().load(); + await s.api().load(); + await s.api().loadPlans(); + await s.api().forTenant('org_9').load(); + }); + expect(lines.where((l) => l.contains('subscription.New')), hasLength(1)); + }); + + test('stays quiet when the server has it', () async { + final lines = await printed(() async { + final s = _setup(serverPlugins: ['subscription']); + await s.manager.initialize(); + await s.api().load(); + }); + expect(lines, isEmpty); + }); + }); + + group('forTenant', () { + test('reads and writes another tenant, with its own cache', () async { + final s = _setup(); + Object? cancelBody; + s.routes['POST /v1/billing/subscription/cancel'] = (req) { + cancelBody = jsonDecode(req.body); + return _json({'subscription': subJson({'tenant_id': 'org_9', 'cancel_at': '2026-12-01T00:00:00Z'})}); + }; + await s.manager.initialize(); + final other = s.api().forTenant('org_9'); + expect(identical(s.api().forTenant('org_9'), other), isTrue); + expect(other.tenantId, 'org_9'); + expect(s.api().tenantId, isNull); + + await other.load(); + expect(s.calls, contains('GET /v1/billing/subscription?tenant_id=org_9')); + expect(s.calls, contains('GET /v1/billing/entitlements?tenant_id=org_9')); + expect(s.api().snapshot.subscription.status, ResourceStatus.idle); + + await other.cancel(); + expect(cancelBody, {'when': 'period_end', 'tenant_id': 'org_9'}); + expect(other.snapshot.subscription.data!.willCancel, isTrue); + }); + + test('keeps a pinned tenant across org switches and forgets it on sign-out', () async { + final s = _setup(); + await s.manager.initialize(); + final other = s.api().forTenant('org_9'); + await other.load(); + s.routes['POST /v1/me/switch-org'] = (_) => _json({'session_id': 's', 'org_id': 'org_2'}); + await s.manager.switchOrg('org_2'); + expect(other.snapshot.subscription.status, ResourceStatus.ready); + await s.manager.signOut(); + expect(other.snapshot.subscription.status, ResourceStatus.idle); + }); + }); } diff --git a/flutter/packages/authsome_subscription_flutter/README.md b/flutter/packages/authsome_subscription_flutter/README.md index 473f49c3..f00ded4a 100644 --- a/flutter/packages/authsome_subscription_flutter/README.md +++ b/flutter/packages/authsome_subscription_flutter/README.md @@ -47,6 +47,8 @@ ManageBillingButton(onOpen: (uri) => launchUrl(uri)); Opening a payment or portal page is left to you, so the package doesn't pull in a URL launcher. Pass `launchUrl` from url_launcher, or whatever you already use. +`CurrentPlanCard` asks before it cancels. Every widget takes a `tenantId` for support staff who hold `billing:manage` and look after a customer's billing, and `context.subscriptionFor(id)` is the API for that tenant. + For your own UI, `context.subscription` is the plugin's API and `SubscriptionBuilder` rebuilds from its snapshot: ```dart diff --git a/flutter/packages/authsome_subscription_flutter/lib/src/subscription_widgets.dart b/flutter/packages/authsome_subscription_flutter/lib/src/subscription_widgets.dart index b9b69ae9..86effa5e 100644 --- a/flutter/packages/authsome_subscription_flutter/lib/src/subscription_widgets.dart +++ b/flutter/packages/authsome_subscription_flutter/lib/src/subscription_widgets.dart @@ -9,16 +9,23 @@ extension SubscriptionContext on BuildContext { /// The subscription plugin's API. The plugin has to be in the /// [AuthConfig.plugins] of the surrounding [AuthProvider]. SubscriptionApi get subscription => AuthProvider.of(this).plugin(); + + /// The API for [tenantId], or the session's own when it's null. Passing a + /// tenant is for operators who hold billing:manage. + SubscriptionApi subscriptionFor(String? tenantId) => + tenantId == null ? subscription : subscription.forTenant(tenantId); } /// Builds from the subscription plugin's snapshot and rebuilds when it /// changes. Loads the subscription (and, with [loadPlans], the plans) the -/// first time it's built while signed in. +/// first time it's built while signed in. Pass [tenantId] to read another +/// tenant than the session's (operators holding billing:manage only). class SubscriptionBuilder extends StatefulWidget { - const SubscriptionBuilder({required this.builder, this.loadPlans = false, super.key}); + const SubscriptionBuilder({required this.builder, this.loadPlans = false, this.tenantId, super.key}); final Widget Function(BuildContext context, SubscriptionSnapshot snapshot) builder; final bool loadPlans; + final String? tenantId; @override State createState() => _SubscriptionBuilderState(); @@ -30,8 +37,18 @@ class _SubscriptionBuilderState extends State { @override void didChangeDependencies() { super.didChangeDependencies(); + _resolve(); + } + + @override + void didUpdateWidget(SubscriptionBuilder oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.tenantId != widget.tenantId) _resolve(); + } + + void _resolve() { final auth = AuthProvider.of(context); - final api = auth.plugin(); + final api = context.subscriptionFor(widget.tenantId); _api = api; // AuthProvider is inherited, so this runs again when the user signs in. if (!auth.isAuthenticated) return; @@ -67,17 +84,19 @@ class EntitlementGate extends StatelessWidget { required this.child, this.fallback = const SizedBox.shrink(), this.loading, + this.tenantId, super.key, }); final String feature; + final String? tenantId; final Widget child; final Widget fallback; final Widget? loading; @override Widget build(BuildContext context) { - return SubscriptionBuilder(builder: (context, snap) { + return SubscriptionBuilder(tenantId: tenantId, builder: (context, snap) { final entitlements = snap.entitlements; if (entitlements.status == ResourceStatus.ready && entitlements.data[feature]?.allowed == true) { @@ -111,11 +130,15 @@ class PricingTable extends StatefulWidget { this.returnUrl, this.cancelUrl, this.onError, + this.tenantId, super.key, }); final ValueChanged? onSelected; + /// Another tenant than the session's, for operators holding billing:manage. + final String? tenantId; + /// Gets the payment page URL when a plan needs paying for. Without it the /// table can't take payment: it tells the user and passes a [StateError] /// to [onError]. @@ -170,6 +193,7 @@ class _PricingTableState extends State { final theme = Theme.of(context); return SubscriptionBuilder( loadPlans: true, + tenantId: widget.tenantId, builder: (context, snap) { if (_isLoading(context, snap.plans)) { return const Center(child: Padding(padding: EdgeInsets.all(24), child: CircularProgressIndicator())); @@ -232,7 +256,7 @@ class _PricingTableState extends State { : FilledButton( onPressed: _busy ? null - : () => _choose(context.subscription, plan, current != null), + : () => _choose(context.subscriptionFor(widget.tenantId), plan, current != null), child: Text('Choose ${plan.name}'), ), ), @@ -252,7 +276,10 @@ class _PricingTableState extends State { /// 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}); + const CurrentPlanCard({this.onError, this.tenantId, super.key}); + + /// Another tenant than the session's, for operators holding billing:manage. + final String? tenantId; /// Called with the error when cancel or keep fails; the card shows plain words. final ValueChanged? onError; @@ -276,6 +303,27 @@ class _CurrentPlanCardState extends State { } } + // Canceling is the one write here that's hard to take back once the + // period ends, so it asks first. + Future _confirmCancel(SubscriptionApi api, Subscription sub) async { + final yes = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Cancel your plan?'), + content: Text('You keep it until ${_date(context, sub.currentPeriodEnd)}.'), + actions: [ + TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Never mind')), + FilledButton( + style: FilledButton.styleFrom(backgroundColor: Theme.of(context).colorScheme.error), + onPressed: () => Navigator.pop(context, true), + child: const Text('Yes, cancel'), + ), + ], + ), + ); + if (yes == true && mounted) await _run(api.cancel); + } + String _status(BuildContext context, Subscription sub) { if (sub.isPaused) return 'Paused'; if (sub.willCancel) return 'Ends on ${_date(context, sub.cancelAt!)}'; @@ -287,7 +335,7 @@ class _CurrentPlanCardState extends State { @override Widget build(BuildContext context) { final theme = Theme.of(context); - return SubscriptionBuilder(builder: (context, snap) { + return SubscriptionBuilder(tenantId: widget.tenantId, builder: (context, snap) { if (_isLoading(context, snap.subscription)) { return const Card(child: Padding(padding: EdgeInsets.all(24), child: LinearProgressIndicator())); } @@ -303,7 +351,7 @@ class _CurrentPlanCardState extends State { ), ); } - final api = context.subscription; + final api = context.subscriptionFor(widget.tenantId); final seats = sub.seats; return Card( child: Padding( @@ -338,7 +386,7 @@ class _CurrentPlanCardState extends State { child: const Text('Keep plan'), ) : OutlinedButton( - onPressed: _busy ? null : () => _run(api.cancel), + onPressed: _busy ? null : () => _confirmCancel(api, sub), child: const Text('Cancel plan'), ), ], @@ -352,9 +400,10 @@ class _CurrentPlanCardState extends State { /// 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}); + const UsageMeter({required this.feature, this.label, this.tenantId, super.key}); final String feature; + final String? tenantId; /// What to call it. Defaults to the feature key. final String? label; @@ -362,7 +411,7 @@ class UsageMeter extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); - return SubscriptionBuilder(builder: (context, snap) { + return SubscriptionBuilder(tenantId: tenantId, builder: (context, snap) { if (_isLoading(context, snap.entitlements)) return const LinearProgressIndicator(); final e = snap.entitlements.data[feature]; if (e == null) return const SizedBox.shrink(); @@ -391,9 +440,10 @@ class UsageMeter extends StatelessWidget { /// 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}); + const ManageBillingButton({required this.onOpen, this.returnUrl, this.child, this.tenantId, super.key}); final ValueChanged onOpen; + final String? tenantId; final String? returnUrl; final Widget? child; @@ -421,10 +471,10 @@ class _ManageBillingButtonState extends State { @override Widget build(BuildContext context) { - return SubscriptionBuilder(builder: (context, snap) { + return SubscriptionBuilder(tenantId: widget.tenantId, builder: (context, snap) { if (!snap.canManage) return const SizedBox.shrink(); return OutlinedButton( - onPressed: _busy ? null : () => _open(context.subscription), + onPressed: _busy ? null : () => _open(context.subscriptionFor(widget.tenantId)), child: widget.child ?? const Text('Manage billing'), ); }); diff --git a/flutter/packages/authsome_subscription_flutter/test/widgets_test.dart b/flutter/packages/authsome_subscription_flutter/test/widgets_test.dart index e7a86770..b593d0eb 100644 --- a/flutter/packages/authsome_subscription_flutter/test/widgets_test.dart +++ b/flutter/packages/authsome_subscription_flutter/test/widgets_test.dart @@ -69,6 +69,9 @@ class _Harness { final routes = {}; final calls = []; + /// The same calls with their query strings. + final urls = []; + Future signedIn() => _auth(withSession: true); Future signedOut() => _auth(withSession: false); @@ -89,6 +92,7 @@ class _Harness { httpClient: MockClient((req) async { final key = '${req.method} ${req.url.path}'; calls.add(key); + urls.add('$key${req.url.hasQuery ? '?${req.url.query}' : ''}'); final handler = routes[key]; return handler == null ? _json({'error': 'no route $key'}, 404) : handler(req); }), @@ -244,11 +248,61 @@ void main() { Object? reported; await h.pump(tester, CurrentPlanCard(onError: (e) => reported = e)); await tester.tap(find.text('Cancel plan')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Yes, cancel')); await _settle(tester); expect(find.text("Couldn't update your plan. Try again."), findsOneWidget); expect(reported, isNotNull); }); + testWidgets('CurrentPlanCard asks before canceling, and cancels only on yes', (tester) async { + final h = _Harness(); + var canceled = 0; + h.routes['POST /v1/billing/subscription/cancel'] = (_) { + canceled++; + return _json({'subscription': subJson({'cancel_at': '2026-12-01T12:00:00Z'})}); + }; + await h.pump(tester, const CurrentPlanCard()); + await tester.tap(find.text('Cancel plan')); + await tester.pumpAndSettle(); + expect(find.text('Cancel your plan?'), findsOneWidget); + expect(find.textContaining('You keep it until'), findsOneWidget); + expect(canceled, 0); + + await tester.tap(find.text('Never mind')); + await tester.pumpAndSettle(); + expect(find.text('Cancel your plan?'), findsNothing); + expect(canceled, 0); + + await tester.tap(find.text('Cancel plan')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Yes, cancel')); + await _settle(tester); + expect(canceled, 1); + expect(find.text('Keep plan'), findsOneWidget); + }); + + testWidgets('CurrentPlanCard shows and changes another tenant when given a tenantId', (tester) async { + final h = _Harness(); + Object? body; + h.routes['POST /v1/billing/subscription/cancel'] = (req) { + body = jsonDecode(req.body); + return _json({'subscription': subJson({'cancel_at': '2026-12-01T12:00:00Z'})}); + }; + await h.pump(tester, const Column(children: [ + CurrentPlanCard(tenantId: 'org_9'), + EntitlementGate(tenantId: 'org_9', feature: 'sso', fallback: Text('locked'), child: Text('open')), + ])); + expect(h.urls, contains('GET /v1/billing/subscription?tenant_id=org_9')); + expect(h.urls, isNot(contains('GET /v1/billing/subscription'))); + expect(find.text('open'), findsOneWidget); + await tester.tap(find.text('Cancel plan')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Yes, cancel')); + await _settle(tester); + expect(body, {'when': 'period_end', 'tenant_id': 'org_9'}); + }); + testWidgets('CurrentPlanCard shows seats and no buttons to a member', (tester) async { final h = _Harness(); h.routes['GET /v1/billing/subscription'] = (_) => _json({ diff --git a/sdkgen/Makefile b/sdkgen/Makefile index b868c478..d5937f10 100644 --- a/sdkgen/Makefile +++ b/sdkgen/Makefile @@ -31,8 +31,8 @@ FLUTTER_CORE_OVERRIDES = refreshTokens=refresh,deleteAccount=deleteMe,exportUser # 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. +# them undeprecated. Rules are separated by semicolons; write a semicolon +# inside a note as \; (sdkgen escapes everything else for each language). UI_CORE_DEPRECATIONS = Billing=Use @authsome/plugin-subscription instead.;Billing =Operator route. Call it from your server with @authsome/client, or use @authsome/plugin-subscription for tenant billing. FLUTTER_CORE_DEPRECATIONS = Billing=Use the authsome_subscription package instead.;Billing =Operator route. Call it from your server with the authsome Dart SDK, or use authsome_subscription for tenant billing. diff --git a/sdkgen/cmd/main.go b/sdkgen/cmd/main.go index bf1023a6..d3405c95 100644 --- a/sdkgen/cmd/main.go +++ b/sdkgen/cmd/main.go @@ -63,7 +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.\")") + deprecateTags := fs.String("deprecate-tags", "", "Semicolon-separated tagPrefix=note rules marking operations deprecated (e.g. \"Billing=Use @authsome/plugin-subscription instead.\"); the longest matching prefix wins, and \\; puts a semicolon in a note") if err := fs.Parse(os.Args[2:]); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) @@ -90,14 +90,7 @@ 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]) - } - } + deprecated := openapi.ParseDeprecationRules(*deprecateTags) // Load or generate OpenAPI spec. var spec *openapi.Spec diff --git a/sdkgen/dart/deprecate_test.go b/sdkgen/dart/deprecate_test.go index aada527e..d43ba070 100644 --- a/sdkgen/dart/deprecate_test.go +++ b/sdkgen/dart/deprecate_test.go @@ -45,3 +45,25 @@ func TestGenerate_DeprecatesTaggedOperations(t *testing.T) { assert.Contains(t, before("getActiveSubscription"), "@Deprecated('Operator route. Call it from your server.')") assert.NotContains(t, before("getMe"), "@Deprecated") } + +// A note is a Dart string literal, so quotes, dollars and backslashes are +// escaped and it can't end the annotation early. +func TestGenerate_EscapesDeprecationNotes(t *testing.T) { + spec, err := openapi.LoadSpecFromFile("../spec.json") + require.NoError(t, err) + + gen := dart.NewGenerator(dart.GeneratorConfig{ + OutputMode: "embedded", + DeprecatedTags: map[string]string{"Billing": `Don't use $this \ here`}, + }) + files, err := gen.Generate(spec) + require.NoError(t, err) + + for _, f := range files { + if strings.HasSuffix(f.Path, "api_client.dart") { + assert.Contains(t, f.Content, `@Deprecated('Don\'t use \$this \\ here')`) + return + } + } + t.Fatal("no api_client.dart") +} diff --git a/sdkgen/dart/escape.go b/sdkgen/dart/escape.go new file mode 100644 index 00000000..3fd04d29 --- /dev/null +++ b/sdkgen/dart/escape.go @@ -0,0 +1,15 @@ +package dart + +import "strings" + +// dartStringBody makes s safe between the single quotes of a Dart string +// literal: backslashes, quotes and dollars (interpolation) are escaped, and +// line breaks become spaces since the literal is single-line. +var dartStringBody = strings.NewReplacer( + `\`, `\\`, + `'`, `\'`, + `$`, `\$`, + "\r\n", " ", + "\n", " ", + "\r", " ", +).Replace diff --git a/sdkgen/dart/generator.go b/sdkgen/dart/generator.go index b74ce237..d4241025 100644 --- a/sdkgen/dart/generator.go +++ b/sdkgen/dart/generator.go @@ -248,7 +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) + opDef.Deprecated = dartStringBody(openapi.DeprecationFor(pair.op.Tags, g.config.DeprecatedTags)) // Determine if auth is required opDef.AuthRequired = openapi.OperationTakesToken(pair.op, spec.SecuritySchemes()) diff --git a/sdkgen/openapi/deprecate.go b/sdkgen/openapi/deprecate.go index 67d6d503..df7745dd 100644 --- a/sdkgen/openapi/deprecate.go +++ b/sdkgen/openapi/deprecate.go @@ -2,6 +2,35 @@ package openapi import "strings" +// ParseDeprecationRules reads the --deprecate-tags flag: tagPrefix=note +// rules separated by semicolons. A note can contain a semicolon written as +// \;. Rules without an "=" or with an empty prefix are skipped. Only the +// whole rule is trimmed, so "Billing =note" keeps the prefix "Billing ". +func ParseDeprecationRules(flag string) map[string]string { + rules := make(map[string]string) + var rule strings.Builder + flush := func() { + parts := strings.SplitN(strings.TrimSpace(rule.String()), "=", 2) + if len(parts) == 2 && parts[0] != "" { + rules[parts[0]] = strings.TrimSpace(parts[1]) + } + rule.Reset() + } + for i := 0; i < len(flag); i++ { + switch { + case flag[i] == '\\' && i+1 < len(flag) && flag[i+1] == ';': + rule.WriteByte(';') + i++ + case flag[i] == ';': + flush() + default: + rule.WriteByte(flag[i]) + } + } + flush() + return rules +} + // DeprecationFor returns the note of the rule whose tag prefix starts one of // tags, or "" when none does. Rules map a tag prefix (e.g. "Billing") to the // note shown to callers. When several match, the longest prefix wins, so diff --git a/sdkgen/openapi/deprecate_test.go b/sdkgen/openapi/deprecate_test.go index ddd52ff5..5c5a4c7d 100644 --- a/sdkgen/openapi/deprecate_test.go +++ b/sdkgen/openapi/deprecate_test.go @@ -22,3 +22,14 @@ func TestDeprecationFor_LongestPrefixWins(t *testing.T) { assert.Empty(t, openapi.DeprecationFor([]string{"Auth"}, rules)) assert.Empty(t, openapi.DeprecationFor([]string{"Billing"}, nil)) } + +// Rules come from one flag, split on semicolons; a note can still contain +// one when it's escaped as \;. +func TestParseDeprecationRules(t *testing.T) { + assert.Equal(t, map[string]string{ + "Billing": "Use the plugin; it's newer.", + "Billing ": "Operator route.", + }, openapi.ParseDeprecationRules(`Billing=Use the plugin\; it's newer.;Billing =Operator route.`)) + assert.Empty(t, openapi.ParseDeprecationRules("")) + assert.Empty(t, openapi.ParseDeprecationRules("no-equals; =no key")) +} diff --git a/sdkgen/typescript/deprecate_test.go b/sdkgen/typescript/deprecate_test.go index 85e310f9..8e938945 100644 --- a/sdkgen/typescript/deprecate_test.go +++ b/sdkgen/typescript/deprecate_test.go @@ -46,3 +46,25 @@ func TestGenerate_DeprecatesTaggedOperations(t *testing.T) { assert.Contains(t, docFor("subscribe"), "@deprecated") assert.NotContains(t, docFor("getMe"), "@deprecated") } + +// A note sits inside a JSDoc block, so "*/" can't close it early. +func TestGenerate_EscapesDeprecationNotes(t *testing.T) { + spec, err := openapi.LoadSpecFromFile("../spec.json") + require.NoError(t, err) + + gen := typescript.NewGenerator(typescript.GeneratorConfig{ + OutputMode: "embedded", + DeprecatedTags: map[string]string{"Billing": "Read docs/*/billing */ first"}, + }) + files, err := gen.Generate(spec) + require.NoError(t, err) + + for _, f := range files { + if strings.HasSuffix(f.Path, "api-client.ts") { + assert.Contains(t, f.Content, `@deprecated Read docs/*\/billing *\/ first`) + assert.NotContains(t, f.Content, "billing */ first") + return + } + } + t.Fatal("no api-client.ts") +} diff --git a/sdkgen/typescript/escape.go b/sdkgen/typescript/escape.go new file mode 100644 index 00000000..091935d3 --- /dev/null +++ b/sdkgen/typescript/escape.go @@ -0,0 +1,12 @@ +package typescript + +import "strings" + +// jsDocText makes s safe inside a /** ... */ block: "*/" would close it, and +// a line break would start a line without the leading " * ". +var jsDocText = strings.NewReplacer( + "*/", `*\/`, + "\r\n", " ", + "\n", " ", + "\r", " ", +).Replace diff --git a/sdkgen/typescript/generator.go b/sdkgen/typescript/generator.go index bea81f9e..20f5e59f 100644 --- a/sdkgen/typescript/generator.go +++ b/sdkgen/typescript/generator.go @@ -279,7 +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) + opDef.Deprecated = jsDocText(openapi.DeprecationFor(pair.op.Tags, g.config.DeprecatedTags)) // Determine if auth is required opDef.AuthRequired = openapi.OperationTakesToken(pair.op, spec.SecuritySchemes()) diff --git a/ui/packages/plugin-subscription/README.md b/ui/packages/plugin-subscription/README.md index ccd4ba73..ced51dfb 100644 --- a/ui/packages/plugin-subscription/README.md +++ b/ui/packages/plugin-subscription/README.md @@ -73,6 +73,12 @@ They're built on `@authsome/ui-components`, so they pick up the same theme as th @source "../node_modules/@authsome/plugin-subscription/dist"; ``` +`CurrentPlanCard` asks before it cancels. Every component, hook and `` takes a `tenantId` for support staff who hold `billing:manage` and look after a customer's billing: + +```tsx + +``` + ## Without React ```ts @@ -86,4 +92,6 @@ 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. +The snapshot follows the session: signing out clears it, and signing in or switching organization reloads it. A response for an organization you've already left is dropped rather than shown. `billing.forTenant(id)` gives you the same API pinned to another tenant, with its own cache. + +If the server doesn't have the subscription plugin, you get one console warning outside production, and the resources read as `unavailable` without a request. diff --git a/ui/packages/plugin-subscription/src/components.test.tsx b/ui/packages/plugin-subscription/src/components.test.tsx index a3cfb950..5d6cd3d6 100644 --- a/ui/packages/plugin-subscription/src/components.test.tsx +++ b/ui/packages/plugin-subscription/src/components.test.tsx @@ -95,6 +95,39 @@ describe("CurrentPlanCard", () => { await waitFor(() => expect(screen.getByText(/Renews on/)).toBeTruthy()); }); + it("asks before canceling, and cancels only on yes", async () => { + const h = harness(); + const cancel = vi.fn(() => json({ subscription: subJSON({ cancel_at: "2026-12-01T00:00:00Z" }) })); + h.routes.set("POST /v1/billing/subscription/cancel", cancel); + await h.mount(); + fireEvent.click(await screen.findByRole("button", { name: "Cancel plan" })); + expect(cancel).not.toHaveBeenCalled(); + expect(screen.getByText("Cancel your plan? You keep it until Dec 1, 2026.")).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: "Never mind" })); + expect(screen.queryByText(/Cancel your plan\?/)).toBeNull(); + expect(cancel).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole("button", { name: "Cancel plan" })); + await act(async () => { fireEvent.click(screen.getByRole("button", { name: "Yes, cancel" })); }); + expect(cancel).toHaveBeenCalledTimes(1); + await waitFor(() => expect(screen.getByText(/Ends on Dec 1, 2026/)).toBeTruthy()); + }); + + it("shows and changes another tenant's plan when given a tenantId", async () => { + const h = harness(); + let body: unknown; + h.routes.set("POST /v1/billing/subscription/cancel", (init) => { + body = JSON.parse(String(init?.body)); + return json({ subscription: subJSON({ cancel_at: "2026-12-01T00:00:00Z" }) }); + }); + await h.mount(); + fireEvent.click(await screen.findByRole("button", { name: "Cancel plan" })); + await act(async () => { fireEvent.click(screen.getByRole("button", { name: "Yes, cancel" })); }); + expect(h.urls).toContain("GET /v1/billing/subscription?tenant_id=org_9"); + expect(body).toEqual({ when: "period_end", tenant_id: "org_9" }); + }); + it("shows no buttons to someone who can't manage billing", async () => { const h = harness(); h.routes.set("GET /v1/billing/subscription", () => json({ subscription: subJSON(), can_manage: false })); diff --git a/ui/packages/plugin-subscription/src/components.tsx b/ui/packages/plugin-subscription/src/components.tsx index 384cbe7a..880f1b71 100644 --- a/ui/packages/plugin-subscription/src/components.tsx +++ b/ui/packages/plugin-subscription/src/components.tsx @@ -26,7 +26,7 @@ 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"; +import { useEntitlement, usePlans, useSubscription, type TenantOptions } from "./react"; const redirect = (url: string) => window.location.assign(url); @@ -44,7 +44,7 @@ function ErrorLine({ children }: { children: ReactNode }) { // ── PricingTable ────────────────────────────────── -export interface PricingTableProps { +export interface PricingTableProps extends TenantOptions { /** Take over selection yourself. Without it the table subscribes or changes plan. */ onSelect?: (plan: Plan) => void; /** Called with the payment page URL when a plan needs paying for. Defaults to redirecting. */ @@ -72,9 +72,10 @@ export function PricingTable({ locale, onError, className, + tenantId, }: PricingTableProps) { const { plans, isLoading, error } = usePlans(); - const { subscription, canManage, subscribe, changePlan, isPending } = useSubscription(); + const { subscription, canManage, subscribe, changePlan, isPending } = useSubscription({ tenantId }); const [failed, setFailed] = useState(false); const choose = async (plan: Plan) => { @@ -172,7 +173,7 @@ export function PricingTable({ // ── CurrentPlanCard ─────────────────────────────── -export interface CurrentPlanCardProps { +export interface CurrentPlanCardProps extends TenantOptions { /** Locale for prices and dates. Defaults to the browser's. */ locale?: string; /** Time zone for dates. Defaults to the browser's. */ @@ -192,12 +193,16 @@ function statusLine(sub: Subscription, locale?: string, timeZone?: string): stri } /** The tenant's plan: name, price, when it renews or ends, seats, and cancel/keep. */ -export function CurrentPlanCard({ locale, timeZone, onError, className }: CurrentPlanCardProps) { +export function CurrentPlanCard({ locale, timeZone, onError, className, tenantId }: CurrentPlanCardProps) { const { subscription: sub, canManage, isLoading, error, cancel, resume, isPending } = - useSubscription(); + useSubscription({ tenantId }); const [failed, setFailed] = useState(null); + // Canceling is the one write here that's hard to take back once the + // period ends, so it asks first. + const [confirming, setConfirming] = useState(false); const run = async (write: () => Promise) => { + setConfirming(false); setFailed(null); try { await write(); @@ -247,13 +252,25 @@ export function CurrentPlanCard({ locale, timeZone, onError, className }: Curren {failed && {failed}} {canManage && !sub.isPaused && ( - + {sub.willCancel ? ( + ) : confirming ? ( + <> +

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

+ + + ) : ( - )} @@ -265,7 +282,7 @@ export function CurrentPlanCard({ locale, timeZone, onError, className }: Curren // ── UsageMeter ──────────────────────────────────── -export interface UsageMeterProps { +export interface UsageMeterProps extends TenantOptions { /** The metered plan feature, e.g. "api_calls". */ feature: string; /** What to call it. Defaults to the feature key. */ @@ -274,8 +291,8 @@ export interface UsageMeterProps { } /** 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); +export function UsageMeter({ feature, label, className, tenantId }: UsageMeterProps) { + const { entitlement, isLoading } = useEntitlement(feature, { tenantId }); if (isLoading) return ; if (!entitlement) return null; @@ -311,7 +328,7 @@ export function UsageMeter({ feature, label, className }: UsageMeterProps) { // ── ManageBillingButton ─────────────────────────── -export interface ManageBillingButtonProps { +export interface ManageBillingButtonProps extends TenantOptions { /** Where the portal sends the customer back to. */ returnUrl?: string; /** Called with the portal URL. Defaults to redirecting. */ @@ -329,8 +346,9 @@ export function ManageBillingButton({ onOpen = redirect, children = "Manage billing", className, + tenantId, }: ManageBillingButtonProps) { - const { canManage, openPortal, isPending } = useSubscription(); + const { canManage, openPortal, isPending } = useSubscription({ tenantId }); const [failed, setFailed] = useState(false); if (!canManage) return null; diff --git a/ui/packages/plugin-subscription/src/plugin.test.ts b/ui/packages/plugin-subscription/src/plugin.test.ts index 433b7907..508e55b5 100644 --- a/ui/packages/plugin-subscription/src/plugin.test.ts +++ b/ui/packages/plugin-subscription/src/plugin.test.ts @@ -324,3 +324,70 @@ describe("writes", () => { expect(listener).toHaveBeenCalled(); }); }); + +describe("a server without the plugin", () => { + it("warns once, naming the fix", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + const { manager, api } = setup({ serverPlugins: ["organization"] }); + await manager.initialize(); + await api().load(); + await api().load(); + await api().loadPlans(); + await api().forTenant("org_9").load(); + expect(warn).toHaveBeenCalledTimes(1); + expect(String(warn.mock.calls[0][0])).toContain("subscription.New"); + } finally { + warn.mockRestore(); + } + }); + + it("stays quiet when the server has it", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + const { manager, api } = setup({ serverPlugins: ["subscription"] }); + await manager.initialize(); + await api().load(); + expect(warn).not.toHaveBeenCalled(); + } finally { + warn.mockRestore(); + } + }); +}); + +describe("forTenant", () => { + it("reads and writes another tenant, with its own cache", async () => { + const { manager, routes, calls, api } = setup(); + let cancelBody: unknown; + routes.set("POST /v1/billing/subscription/cancel", (init) => { + cancelBody = JSON.parse(String(init?.body)); + return json({ subscription: subJSON({ tenant_id: "org_9", cancel_at: "2026-12-01T00:00:00Z" }) }); + }); + await manager.initialize(); + const other = api().forTenant("org_9"); + expect(api().forTenant("org_9")).toBe(other); + expect(other.tenantId).toBe("org_9"); + expect(api().tenantId).toBeNull(); + + await other.load(); + expect(calls).toContain("GET /v1/billing/subscription?tenant_id=org_9"); + expect(calls).toContain("GET /v1/billing/entitlements?tenant_id=org_9"); + expect(api().getSnapshot().subscription.status).toBe("idle"); + + await other.cancel(); + expect(cancelBody).toEqual({ when: "period_end", tenant_id: "org_9" }); + expect(other.getSnapshot().subscription.data?.willCancel).toBe(true); + }); + + it("keeps a pinned tenant across org switches and forgets it on sign-out", async () => { + const { manager, routes, api } = setup(); + await manager.initialize(); + const other = api().forTenant("org_9"); + await other.load(); + routes.set("POST /v1/me/switch-org", () => json({ session_id: "s", org_id: "org_2" })); + await manager.switchOrg("org_2"); + expect(other.getSnapshot().subscription.status).toBe("ready"); + await manager.signOut(); + expect(other.getSnapshot().subscription.status).toBe("idle"); + }); +}); diff --git a/ui/packages/plugin-subscription/src/plugin.ts b/ui/packages/plugin-subscription/src/plugin.ts index cd87ddf9..c04e6d06 100644 --- a/ui/packages/plugin-subscription/src/plugin.ts +++ b/ui/packages/plugin-subscription/src/plugin.ts @@ -49,6 +49,17 @@ export interface ChangeOptions { } export interface SubscriptionApi { + /** + * The tenant this API is pinned to, or null for the session's own (its + * active organization, or the user in user mode). + */ + readonly tenantId: string | null; + /** + * The same API for another tenant, with its own cache. For operators who + * hold billing:manage and look after customers' billing; the server + * refuses anyone else. Calling it twice with one ID returns one instance. + */ + forTenant(tenantId: string): SubscriptionApi; getSnapshot(): SubscriptionSnapshot; /** Listen for snapshot changes. Returns a function that stops listening. */ onChange(listener: () => void): () => void; @@ -87,8 +98,31 @@ function asError(err: unknown): Error { return err instanceof Error ? err : new Error(String(err)); } +// State every scope of one plugin instance shares. +interface Shared { + scopes: Map; + warned: boolean; +} + +const UNAVAILABLE_WARNING = + "authsome: the server doesn't have the subscription plugin, so billing reads as " + + "unavailable and every entitlement check fails closed. Register it on the server with " + + "authsome.WithPlugin(subscription.New(...)), or drop subscription() from your client plugins."; + +function isProduction(): boolean { + const env = (globalThis as { process?: { env?: Record } }).process?.env; + return env?.NODE_ENV === "production"; +} + /** Builds the subscription API over a plugin context. */ export function createSubscriptionApi(ctx: PluginContext): SubscriptionApi { + return createScope(ctx, null, { scopes: new Map(), warned: false }); +} + +function createScope(ctx: PluginContext, tenantId: string | null, shared: Shared): SubscriptionApi { + // A pinned scope names its tenant on every request; the session's scope + // leaves it to the server. + const scoped: Record = tenantId ? { tenant_id: tenantId } : {}; let snapshot = initialSnapshot(); const listeners = new Set<() => void>(); // Bumped whenever the session or organization changes. Anything that @@ -113,7 +147,14 @@ export function createSubscriptionApi(ctx: PluginContext): SubscriptionApi { } }; - const unavailable = () => ctx.isServerEnabled() === false; + const unavailable = () => { + if (ctx.isServerEnabled() !== false) return false; + if (!shared.warned && !isProduction()) { + shared.warned = true; + console.warn(UNAVAILABLE_WARNING); + } + return true; + }; const signedIn = () => ctx.getState().status === "authenticated"; const loadEntitlements = async (gen: number) => { @@ -122,6 +163,7 @@ export function createSubscriptionApi(ctx: PluginContext): SubscriptionApi { const res = await ctx.request<{ entitlements: Record }>( "GET", "/v1/billing/entitlements", + { query: scoped }, ); if (gen !== generation) return; const entitlements: Record = {}; @@ -142,6 +184,7 @@ export function createSubscriptionApi(ctx: PluginContext): SubscriptionApi { const res = await ctx.request<{ subscription: SubscriptionJSON | null; can_manage: boolean }>( "GET", "/v1/billing/subscription", + { query: scoped }, ); if (gen !== generation) return; set({ @@ -233,7 +276,7 @@ export function createSubscriptionApi(ctx: PluginContext): SubscriptionApi { const res = await ctx.request<{ subscription?: SubscriptionJSON; checkout_url?: string }>( method, "/v1/billing/subscription", - { body }, + { body: { ...body, ...scoped } }, ); if (res.checkout_url) return { type: "checkout", url: res.checkout_url }; const sub = commit(res.subscription, gen); @@ -248,9 +291,11 @@ export function createSubscriptionApi(ctx: PluginContext): SubscriptionApi { ...(options?.cancelUrl ? { cancel_url: options.cancelUrl } : {}), }); - const mutate = async (path: string, body: unknown): Promise => { + const mutate = async (path: string, body: Record): Promise => { const gen = generation; - const res = await ctx.request<{ subscription?: SubscriptionJSON }>("POST", path, { body }); + const res = await ctx.request<{ subscription?: SubscriptionJSON }>("POST", path, { + body: { ...body, ...scoped }, + }); const sub = commit(res.subscription, gen); if (!sub) throw new Error("authsome: the server answered without a subscription"); return sub; @@ -259,7 +304,8 @@ export function createSubscriptionApi(ctx: PluginContext): SubscriptionApi { // 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. + // loads) and reloads whatever had been loaded. A pinned tenant doesn't + // change with the organization, but can_manage does change with the user. ctx.on("signedOut", () => { generation++; snapshot = initialSnapshot(); @@ -276,9 +322,18 @@ export function createSubscriptionApi(ctx: PluginContext): SubscriptionApi { void load(); }; ctx.on("signedIn", reload); - ctx.on("orgChanged", reload); + if (!tenantId) ctx.on("orgChanged", reload); return { + tenantId, + forTenant(id) { + let scope = shared.scopes.get(id); + if (!scope) { + scope = createScope(ctx, id, shared); + shared.scopes.set(id, scope); + } + return scope; + }, getSnapshot: () => snapshot, onChange(listener) { listeners.add(listener); @@ -295,7 +350,7 @@ export function createSubscriptionApi(ctx: PluginContext): SubscriptionApi { 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 } : {}, + body: { ...(returnUrl ? { return_url: returnUrl } : {}), ...scoped }, }); return res.url; }, diff --git a/ui/packages/plugin-subscription/src/react.test.tsx b/ui/packages/plugin-subscription/src/react.test.tsx index d93ddf43..561f7e98 100644 --- a/ui/packages/plugin-subscription/src/react.test.tsx +++ b/ui/packages/plugin-subscription/src/react.test.tsx @@ -5,6 +5,19 @@ import { Entitled, usePlans, useSubscription } from "./react"; import { harness, json, planJSON, subJSON } from "./test-support"; describe("useSubscription", () => { + it("reads another tenant when given a tenantId", async () => { + const h = harness(); + let seen = null as unknown as ReturnType; + function Probe() { + seen = useSubscription({ tenantId: "org_9" }); + return null; + } + await h.mount(); + await waitFor(() => expect(seen.subscription?.id).toBe("sub_1")); + expect(h.urls).toContain("GET /v1/billing/subscription?tenant_id=org_9"); + expect(h.urls).not.toContain("GET /v1/billing/subscription"); + }); + it("loads on mount and re-renders after a plan change", async () => { const h = harness(); h.routes.set("PATCH /v1/billing/subscription", () => json({ subscription: subJSON({ plan: planJSON("team") }) })); diff --git a/ui/packages/plugin-subscription/src/react.tsx b/ui/packages/plugin-subscription/src/react.tsx index 4ece143e..4912e8b6 100644 --- a/ui/packages/plugin-subscription/src/react.tsx +++ b/ui/packages/plugin-subscription/src/react.tsx @@ -29,9 +29,19 @@ import { type SubscriptionSnapshot, } from "./plugin"; +/** + * Which tenant a hook or component is about. Leave it out for the session's + * own; operators holding billing:manage can pass a customer's. + */ +export interface TenantOptions { + tenantId?: string; +} + /** The plugin's API, for anything the hooks don't cover. */ -export function useSubscriptionApi(): SubscriptionApi { - return usePlugin(subscription); +export function useSubscriptionApi(options?: TenantOptions): SubscriptionApi { + const api = usePlugin(subscription); + // forTenant hands back one instance per ID, so this is stable. + return options?.tenantId ? api.forTenant(options.tenantId) : api; } function useSnapshot(api: SubscriptionApi): SubscriptionSnapshot { @@ -85,8 +95,8 @@ export interface UseSubscriptionResult { * 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(); +export function useSubscription(options?: TenantOptions): UseSubscriptionResult { + const api = useSubscriptionApi(options); const snap = useSnapshot(api); const signedIn = useAutoLoad(snap.subscription.status, api.load); const { isPending, track } = usePendingWrites(); @@ -142,13 +152,13 @@ export function usePlans(): { * 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): { +export function useEntitlement(feature: string, options?: TenantOptions): { allowed: boolean; entitlement: Entitlement | null; status: Resource["status"]; isLoading: boolean; } { - const api = useSubscriptionApi(); + const api = useSubscriptionApi(options); const snap = useSnapshot(api); // Entitlements load with the subscription. const signedIn = useAutoLoad(snap.subscription.status, api.load); @@ -161,7 +171,7 @@ export function useEntitlement(feature: string): { }; } -export interface EntitledProps { +export interface EntitledProps extends TenantOptions { /** The plan feature to check, e.g. "sso". */ feature: string; children: ReactNode; @@ -176,8 +186,8 @@ export interface EntitledProps { * 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); +export function Entitled({ feature, children, fallback = null, loading, tenantId }: EntitledProps) { + const { allowed, isLoading } = useEntitlement(feature, { tenantId }); if (allowed) return <>{children}; if (isLoading) return <>{loading ?? fallback}; return <>{fallback}; diff --git a/ui/packages/plugin-subscription/src/test-support.tsx b/ui/packages/plugin-subscription/src/test-support.tsx index 065b66f5..460a7876 100644 --- a/ui/packages/plugin-subscription/src/test-support.tsx +++ b/ui/packages/plugin-subscription/src/test-support.tsx @@ -46,10 +46,13 @@ export function harness(opts: { serverPlugins?: string[] } = {}) { ["GET /v1/billing/plans", () => json({ plans: [planJSON("free", { amount_minor: undefined, base_amount: undefined }), planJSON("pro"), planJSON("team", { amount_minor: 9900, base_amount: "99.00" })] })], ]); const calls: string[] = []; + // The same calls with their query strings, for tests that care. + const urls: string[] = []; const fetchFn = (async (input: string | URL, init?: RequestInit) => { const url = new URL(String(input)); const key = `${init?.method ?? "GET"} ${url.pathname}`; calls.push(key); + urls.push(`${key}${url.search}`); const handler = routes.get(key); return handler ? handler(init) : json({ error: `no route ${key}` }, 404); }) as unknown as typeof fetch; @@ -76,6 +79,7 @@ export function harness(opts: { serverPlugins?: string[] } = {}) { return { routes, calls, + urls, async mount(node: ReactNode): Promise { await act(async () => { render(wrap(node));