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