Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions docs/content/docs/plugins/subscription.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
<CurrentPlanCard tenantId={customer.orgId} />
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/...`)
Expand Down
4 changes: 3 additions & 1 deletion flutter/packages/authsome_subscription/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <String, SubscriptionApi>{};
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<SubscriptionApi>()` 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(
Expand All @@ -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;
}
Expand All @@ -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<AuthEvent> _events;
final _changes = StreamController<SubscriptionSnapshot>.broadcast();
SubscriptionSnapshot _snapshot = SubscriptionSnapshot.initial();
Expand All @@ -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<String, String>? get _query => tenantId == null ? null : {'tenant_id': tenantId!};
Map<String, Object?> _scoped(Map<String, Object?> 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
Expand Down Expand Up @@ -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(
Expand All @@ -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<String, dynamic>.from(res['entitlements'] as Map? ?? const {});
_set(_snapshot.copyWith(
Expand Down Expand Up @@ -265,7 +311,7 @@ class SubscriptionApi {
/// A link to the payment provider's billing portal.
Future<Uri> 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);
}

Expand All @@ -281,15 +327,15 @@ class SubscriptionApi {

Future<ChangeResult> _change(String method, Map<String, Object?> 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));
}

Future<Subscription> _mutate(String path, Map<String, Object?> 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);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -384,4 +384,72 @@ void main() {
expect(seen, isNotEmpty);
});
});

group('a server without the plugin', () {
Future<List<String>> printed(Future<void> Function() body) async {
final lines = <String>[];
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);
});
});
}
2 changes: 2 additions & 0 deletions flutter/packages/authsome_subscription_flutter/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading