diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index f502fd00..1c323f8d 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -226,6 +226,13 @@ jobs:
cd ui/packages/nextjs
npm publish --provenance --access public ${{ github.event.inputs.dry_run == 'true' && '--dry-run' || '' }}
+ # Trusted publishing only works for a package that already exists on
+ # npm, so the first release of this one is published by hand.
+ - name: Publish @authsome/plugin-subscription
+ run: |
+ cd ui/packages/plugin-subscription
+ npm publish --provenance --access public ${{ github.event.inputs.dry_run == 'true' && '--dry-run' || '' }}
+
# ─── Flutter/pub.dev Release ─────────────────────────────────────────
# Authentication is OIDC, provisioned inside the reusable workflow -- there is
# no pub credential to configure here. Two consequences:
@@ -238,6 +245,11 @@ jobs:
# 2. pub.dev rejects automated publishing from anything but a tag push, so a
# workflow_dispatch run can only ever rehearse. dry-run is forced on for
# those, which is also what the dry_run input maps to on a tag push.
+ #
+ # Automated publishing can only be switched on for a package that already
+ # exists on pub.dev, so authsome_subscription and
+ # authsome_subscription_flutter go up by hand (`dart pub publish`) once,
+ # then get the tag pattern above. Until then their step here fails.
flutter-release:
name: Flutter Release
needs: [prepare]
@@ -252,5 +264,5 @@ jobs:
with:
# Ordered: authsome_flutter depends on authsome_core, and
# authsome_flutter_ui depends on authsome_flutter.
- packages: '["flutter/packages/authsome_core", "flutter/packages/authsome_flutter", "flutter/packages/authsome_flutter_ui"]'
+ packages: '["flutter/packages/authsome_core", "flutter/packages/authsome_flutter", "flutter/packages/authsome_flutter_ui", "flutter/packages/authsome_subscription", "flutter/packages/authsome_subscription_flutter"]'
dry-run: ${{ github.event_name != 'push' || github.event.inputs.dry_run == 'true' }}
diff --git a/api/me_active_org_test.go b/api/me_active_org_test.go
index 61480fa2..cb37330f 100644
--- a/api/me_active_org_test.go
+++ b/api/me_active_org_test.go
@@ -44,4 +44,9 @@ func TestGetMe_ActiveOrgID(t *testing.T) {
withOrg := *sess
withOrg.OrgID = orgID
assert.Equal(t, orgID.String(), get(middleware.WithSession(base, &withOrg))["active_org_id"])
+
+ // A JWT carries the organization in its claims and there's no session
+ // row; the auth middleware puts the org on the context instead.
+ jwtOrg := id.NewOrgID()
+ assert.Equal(t, jwtOrg.String(), get(middleware.WithOrgID(base, jwtOrg))["active_org_id"])
}
diff --git a/docs/content/docs/plugins/subscription.mdx b/docs/content/docs/plugins/subscription.mdx
index 46af8af9..f437fa44 100644
--- a/docs/content/docs/plugins/subscription.mdx
+++ b/docs/content/docs/plugins/subscription.mdx
@@ -147,6 +147,76 @@ route a tenant's billing writes to one of them, or check for an existing subscri
webhook.
+## In React
+
+The client half ships as `@authsome/plugin-subscription`. Add it to your provider the way you added the server plugin to the engine:
+
+```tsx
+import { subscription } from "@authsome/plugin-subscription";
+import { useSubscription, Entitled } from "@authsome/plugin-subscription/react";
+import { PricingTable, CurrentPlanCard } from "@authsome/plugin-subscription/components";
+
+
+
+
+
+function Billing() {
+ const { plan, subscription, changePlan, isPending } = useSubscription();
+ return (
+ <>
+
+
+ >
+ );
+}
+
+}>
+
+
+```
+
+Everything reading the subscription shares one cache. A write updates it, a sign-out clears it, and switching organization reloads it. `` shows the fallback until it has a positive answer, so loading or an outage never opens a paid feature.
+
+## In Flutter
+
+Two packages: `authsome_subscription` (pure Dart: models and state) and `authsome_subscription_flutter` (widgets).
+
+```dart
+AuthProvider(
+ config: AuthConfig(baseUrl: url, plugins: const [SubscriptionPlugin()]),
+ child: const MyApp(),
+);
+
+const CurrentPlanCard();
+PricingTable(onCheckout: (uri) => launchUrl(uri));
+const EntitlementGate(feature: 'sso', fallback: UpgradePrompt(), child: SsoSettings());
+
+final billing = context.subscription;
+final result = await billing.changePlan('team');
+```
+
+The widgets hand checkout and portal URLs back to you (`onCheckout`, `onOpen`), so the packages don't depend on a URL launcher. On both platforms the components show plain words when a write fails and pass the server's error to `onError`.
+
+`CurrentPlanCard` asks before it cancels and says when the plan ends. If the server doesn't have the subscription plugin, the client packages say so once in the console during development, and every entitlement check fails closed.
+
+The billing methods on the core `ui-core` and `authsome_core` clients still work, and are marked deprecated in favour of these packages.
+
+### Another tenant's billing
+
+Support staff who hold `billing:manage` can look after a customer's billing from the same components. Pass the customer's tenant and every read and write carries it as `tenant_id`:
+
+```tsx
+
+const { subscription } = useSubscription({ tenantId: customer.orgId });
+```
+
+```dart
+CurrentPlanCard(tenantId: customer.orgId);
+final billing = context.subscriptionFor(customer.orgId);
+```
+
+Underneath that's `api.forTenant(id)`, which returns the same API with its own cache. It keeps its tenant when you switch organization and clears when you sign out. The server refuses anyone without `billing:manage`.
+
## Operator routes
Plans, coupons, invoices and the ID-based subscription routes (`/subscriptions/:subId/...`)
diff --git a/flutter/packages/authsome_core/lib/src/generated/api_client.dart b/flutter/packages/authsome_core/lib/src/generated/api_client.dart
index 5a460821..83528f36 100644
--- a/flutter/packages/authsome_core/lib/src/generated/api_client.dart
+++ b/flutter/packages/authsome_core/lib/src/generated/api_client.dart
@@ -1081,6 +1081,7 @@ class AuthClient {
/// List coupons
/// GET /v1/billing/coupons
+ @Deprecated('Operator route. Call it from your server with the authsome Dart SDK, or use authsome_subscription for tenant billing.')
Future listCoupons({required String appId, required String token}) async {
final path = '/v1/billing/coupons';
// A list of pairs rather than a map, because a query string may carry the
@@ -1101,6 +1102,7 @@ class AuthClient {
/// Create coupon
/// POST /v1/billing/coupons
+ @Deprecated('Operator route. Call it from your server with the authsome Dart SDK, or use authsome_subscription for tenant billing.')
Future createCoupon({required CreateCouponRequest body, required String token}) async {
final path = '/v1/billing/coupons';
final res = await _request(
@@ -1114,6 +1116,7 @@ class AuthClient {
/// Delete coupon
/// DELETE /v1/billing/coupons/{couponId}
+ @Deprecated('Operator route. Call it from your server with the authsome Dart SDK, or use authsome_subscription for tenant billing.')
Future deleteCoupon({required String couponId, required String token}) async {
final path = '/v1/billing/coupons/$couponId';
await _request(
@@ -1125,6 +1128,7 @@ class AuthClient {
/// List your entitlements
/// GET /v1/billing/entitlements
+ @Deprecated('Use the authsome_subscription package instead.')
Future listEntitlements({required String token, String? tenantId}) async {
final path = '/v1/billing/entitlements';
// A list of pairs rather than a map, because a query string may carry the
@@ -1145,6 +1149,7 @@ class AuthClient {
/// Check feature entitlement
/// GET /v1/billing/entitlements/{featureKey}
+ @Deprecated('Operator route. Call it from your server with the authsome Dart SDK, or use authsome_subscription for tenant billing.')
Future checkEntitlement({required String featureKey, required String tenantId, required String token, String? appId}) async {
final path = '/v1/billing/entitlements/$featureKey';
// A list of pairs rather than a map, because a query string may carry the
@@ -1166,6 +1171,7 @@ class AuthClient {
/// List invoices
/// GET /v1/billing/invoices
+ @Deprecated('Operator route. Call it from your server with the authsome Dart SDK, or use authsome_subscription for tenant billing.')
Future listInvoices({required String appId, required String tenantId, required String token}) async {
final path = '/v1/billing/invoices';
// A list of pairs rather than a map, because a query string may carry the
@@ -1187,6 +1193,7 @@ class AuthClient {
/// Get invoice
/// GET /v1/billing/invoices/{invoiceId}
+ @Deprecated('Operator route. Call it from your server with the authsome Dart SDK, or use authsome_subscription for tenant billing.')
Future getInvoice({required String invoiceId, required String token}) async {
final path = '/v1/billing/invoices/$invoiceId';
final res = await _request(
@@ -1199,6 +1206,7 @@ class AuthClient {
/// Mark invoice as paid
/// POST /v1/billing/invoices/{invoiceId}/pay
+ @Deprecated('Operator route. Call it from your server with the authsome Dart SDK, or use authsome_subscription for tenant billing.')
Future markInvoicePaid({required String invoiceId, required MarkInvoicePaidRequest body, required String token}) async {
final path = '/v1/billing/invoices/$invoiceId/pay';
await _request(
@@ -1211,6 +1219,7 @@ class AuthClient {
/// Void an invoice
/// POST /v1/billing/invoices/{invoiceId}/void
+ @Deprecated('Operator route. Call it from your server with the authsome Dart SDK, or use authsome_subscription for tenant billing.')
Future voidInvoice({required String invoiceId, required VoidInvoiceRequest body, required String token}) async {
final path = '/v1/billing/invoices/$invoiceId/void';
await _request(
@@ -1223,6 +1232,7 @@ class AuthClient {
/// List billing plans
/// GET /v1/billing/plans
+ @Deprecated('Operator route. Call it from your server with the authsome Dart SDK, or use authsome_subscription for tenant billing.')
Future listBillingPlans({required String appId, required String token, String? status, bool? includeAddons}) async {
final path = '/v1/billing/plans';
// A list of pairs rather than a map, because a query string may carry the
@@ -1245,6 +1255,7 @@ class AuthClient {
/// Create billing plan
/// POST /v1/billing/plans
+ @Deprecated('Operator route. Call it from your server with the authsome Dart SDK, or use authsome_subscription for tenant billing.')
Future createBillingPlan({required CreatePlanRequest body, required String token}) async {
final path = '/v1/billing/plans';
final res = await _request(
@@ -1258,6 +1269,7 @@ class AuthClient {
/// Get billing plan
/// GET /v1/billing/plans/{planId}
+ @Deprecated('Operator route. Call it from your server with the authsome Dart SDK, or use authsome_subscription for tenant billing.')
Future getBillingPlan({required String planId, required String token}) async {
final path = '/v1/billing/plans/$planId';
final res = await _request(
@@ -1270,6 +1282,7 @@ class AuthClient {
/// Activate a billing plan
/// POST /v1/billing/plans/{planId}/activate
+ @Deprecated('Operator route. Call it from your server with the authsome Dart SDK, or use authsome_subscription for tenant billing.')
Future activateBillingPlan({required String planId, required String token}) async {
final path = '/v1/billing/plans/$planId/activate';
await _request(
@@ -1281,6 +1294,7 @@ class AuthClient {
/// Archive a billing plan
/// POST /v1/billing/plans/{planId}/archive
+ @Deprecated('Operator route. Call it from your server with the authsome Dart SDK, or use authsome_subscription for tenant billing.')
Future archiveBillingPlan({required String planId, required String token}) async {
final path = '/v1/billing/plans/$planId/archive';
await _request(
@@ -1292,6 +1306,7 @@ class AuthClient {
/// Open the billing portal
/// POST /v1/billing/portal
+ @Deprecated('Use the authsome_subscription package instead.')
Future createBillingPortalSession({required PortalRequest body, required String token}) async {
final path = '/v1/billing/portal';
final res = await _request(
@@ -1305,6 +1320,7 @@ class AuthClient {
/// Get your subscription
/// GET /v1/billing/subscription
+ @Deprecated('Use the authsome_subscription package instead.')
Future getCurrentSubscription({required String token, String? tenantId}) async {
final path = '/v1/billing/subscription';
// A list of pairs rather than a map, because a query string may carry the
@@ -1325,6 +1341,7 @@ class AuthClient {
/// Subscribe to a plan
/// POST /v1/billing/subscription
+ @Deprecated('Use the authsome_subscription package instead.')
Future subscribe({required SubscribeRequest body, required String token}) async {
final path = '/v1/billing/subscription';
final res = await _request(
@@ -1338,6 +1355,7 @@ class AuthClient {
/// Change plan or seats
/// PATCH /v1/billing/subscription
+ @Deprecated('Use the authsome_subscription package instead.')
Future updateSubscription({required UpdateSubscriptionRequest body, required String token}) async {
final path = '/v1/billing/subscription';
final res = await _request(
@@ -1351,6 +1369,7 @@ class AuthClient {
/// Cancel your subscription
/// POST /v1/billing/subscription/cancel
+ @Deprecated('Use the authsome_subscription package instead.')
Future cancelCurrentSubscription({required CancelCurrentRequest body, required String token}) async {
final path = '/v1/billing/subscription/cancel';
final res = await _request(
@@ -1364,6 +1383,7 @@ class AuthClient {
/// Keep your subscription
/// POST /v1/billing/subscription/resume
+ @Deprecated('Use the authsome_subscription package instead.')
Future resumeCurrentSubscription({required TenantRequest body, required String token}) async {
final path = '/v1/billing/subscription/resume';
final res = await _request(
@@ -1377,6 +1397,7 @@ class AuthClient {
/// List subscriptions
/// GET /v1/billing/subscriptions
+ @Deprecated('Operator route. Call it from your server with the authsome Dart SDK, or use authsome_subscription for tenant billing.')
Future listSubscriptions({required String appId, required String tenantId, required String token, String? status}) async {
final path = '/v1/billing/subscriptions';
// A list of pairs rather than a map, because a query string may carry the
@@ -1399,6 +1420,7 @@ class AuthClient {
/// Create subscription
/// POST /v1/billing/subscriptions
+ @Deprecated('Operator route. Call it from your server with the authsome Dart SDK, or use authsome_subscription for tenant billing.')
Future createSubscription({required CreateSubscriptionRequest body, required String token}) async {
final path = '/v1/billing/subscriptions';
final res = await _request(
@@ -1412,6 +1434,7 @@ class AuthClient {
/// Get active subscription
/// GET /v1/billing/subscriptions/active
+ @Deprecated('Operator route. Call it from your server with the authsome Dart SDK, or use authsome_subscription for tenant billing.')
Future getActiveSubscription({required String appId, required String tenantId, required String token}) async {
final path = '/v1/billing/subscriptions/active';
// A list of pairs rather than a map, because a query string may carry the
@@ -1433,6 +1456,7 @@ class AuthClient {
/// Cancel subscription
/// POST /v1/billing/subscriptions/{subId}/cancel
+ @Deprecated('Operator route. Call it from your server with the authsome Dart SDK, or use authsome_subscription for tenant billing.')
Future cancelSubscription({required String subId, required CancelSubscriptionRequest body, required String token}) async {
final path = '/v1/billing/subscriptions/$subId/cancel';
await _request(
@@ -1445,6 +1469,7 @@ class AuthClient {
/// Change subscription plan
/// POST /v1/billing/subscriptions/{subId}/change-plan
+ @Deprecated('Operator route. Call it from your server with the authsome Dart SDK, or use authsome_subscription for tenant billing.')
Future changeSubscriptionPlan({required String subId, required ChangePlanRequest body, required String token}) async {
final path = '/v1/billing/subscriptions/$subId/change-plan';
await _request(
@@ -1457,6 +1482,7 @@ class AuthClient {
/// Pause subscription
/// POST /v1/billing/subscriptions/{subId}/pause
+ @Deprecated('Operator route. Call it from your server with the authsome Dart SDK, or use authsome_subscription for tenant billing.')
Future pauseSubscription({required String subId, required String token}) async {
final path = '/v1/billing/subscriptions/$subId/pause';
await _request(
@@ -1468,6 +1494,7 @@ class AuthClient {
/// Resume subscription
/// POST /v1/billing/subscriptions/{subId}/resume
+ @Deprecated('Operator route. Call it from your server with the authsome Dart SDK, or use authsome_subscription for tenant billing.')
Future resumeSubscription({required String subId, required String token}) async {
final path = '/v1/billing/subscriptions/$subId/resume';
await _request(
@@ -1479,6 +1506,7 @@ class AuthClient {
/// Get usage summary
/// GET /v1/billing/usage
+ @Deprecated('Operator route. Call it from your server with the authsome Dart SDK, or use authsome_subscription for tenant billing.')
Future getUsageSummary({required String appId, required String tenantId, required String token}) async {
final path = '/v1/billing/usage';
// A list of pairs rather than a map, because a query string may carry the
diff --git a/flutter/packages/authsome_subscription/CHANGELOG.md b/flutter/packages/authsome_subscription/CHANGELOG.md
new file mode 100644
index 00000000..62094844
--- /dev/null
+++ b/flutter/packages/authsome_subscription/CHANGELOG.md
@@ -0,0 +1,5 @@
+# Changelog
+
+## 1.6.1
+
+- First release.
diff --git a/flutter/packages/authsome_subscription/LICENSE b/flutter/packages/authsome_subscription/LICENSE
new file mode 100644
index 00000000..ba6a044b
--- /dev/null
+++ b/flutter/packages/authsome_subscription/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Xraph
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/flutter/packages/authsome_subscription/README.md b/flutter/packages/authsome_subscription/README.md
new file mode 100644
index 00000000..124a6dde
--- /dev/null
+++ b/flutter/packages/authsome_subscription/README.md
@@ -0,0 +1,47 @@
+# authsome_subscription
+
+The client half of authsome's `subscription` plugin, for Dart and Flutter. You get the tenant's subscription, its plan, seats and feature checks as typed models, and a stream of changes. For widgets, add [`authsome_subscription_flutter`](https://pub.dev/packages/authsome_subscription_flutter).
+
+```yaml
+dependencies:
+ authsome_core: ^1.6.1
+ authsome_subscription: ^1.6.1
+```
+
+The server needs the plugin too: `authsome.WithPlugin(subscription.New(...))`.
+
+## Usage
+
+```dart
+final auth = AuthManager(AuthConfig(
+ baseUrl: 'https://api.example.com',
+ plugins: const [SubscriptionPlugin()],
+));
+await auth.initialize();
+
+final billing = auth.plugin();
+await billing.load();
+
+final sub = billing.snapshot.subscription.data;
+if (sub != null && sub.willCancel) {
+ print('${sub.plan.name} ends on ${sub.cancelAt}');
+}
+```
+
+Writes update the snapshot, so anything listening to `billing.changes` sees the new state:
+
+```dart
+final result = await billing.changePlan('team');
+switch (result) {
+ case SubscriptionChanged(:final subscription):
+ print('now on ${subscription.plan.name}');
+ case CheckoutRequired(:final url):
+ await launchUrl(url); // payment first; nothing changed yet
+}
+```
+
+`cancel()` cancels at the end of the period unless you pass `when: CancelWhen.now`, and `resume()` takes that back. `billing.entitlement('sso')` reads the cached check for a feature.
+
+The snapshot follows the session: signing out clears it, and signing in or switching organization reloads it. When the server doesn't have the subscription plugin, the resources come back `ResourceStatus.unavailable` without a request, and debug builds print one warning saying so.
+
+`billing.forTenant(orgId)` returns the same API pinned to another tenant, with its own cache, for support staff who hold `billing:manage`. It keeps its tenant across organization switches and clears on sign-out.
diff --git a/flutter/packages/authsome_subscription/analysis_options.yaml b/flutter/packages/authsome_subscription/analysis_options.yaml
new file mode 100644
index 00000000..5cb95a45
--- /dev/null
+++ b/flutter/packages/authsome_subscription/analysis_options.yaml
@@ -0,0 +1,7 @@
+include: package:lints/recommended.yaml
+
+linter:
+ rules:
+ prefer_single_quotes: true
+ sort_constructors_first: false
+ avoid_dynamic_calls: false
diff --git a/flutter/packages/authsome_subscription/lib/authsome_subscription.dart b/flutter/packages/authsome_subscription/lib/authsome_subscription.dart
new file mode 100644
index 00000000..af3d8917
--- /dev/null
+++ b/flutter/packages/authsome_subscription/lib/authsome_subscription.dart
@@ -0,0 +1,16 @@
+/// The client half of authsome's subscription plugin.
+///
+/// ```dart
+/// final auth = AuthManager(AuthConfig(
+/// baseUrl: 'https://api.example.com',
+/// plugins: [SubscriptionPlugin()],
+/// ));
+/// final billing = auth.plugin();
+/// await billing.load();
+/// ```
+///
+/// Flutter widgets are in `authsome_subscription_flutter`.
+library authsome_subscription;
+
+export 'src/models.dart';
+export 'src/subscription_api.dart';
diff --git a/flutter/packages/authsome_subscription/lib/src/models.dart b/flutter/packages/authsome_subscription/lib/src/models.dart
new file mode 100644
index 00000000..200db0c4
--- /dev/null
+++ b/flutter/packages/authsome_subscription/lib/src/models.dart
@@ -0,0 +1,265 @@
+/// Typed models for the subscription plugin, mapped from the server's JSON.
+library;
+
+/// A subscription's state. A value from a newer server reads as [unknown].
+enum SubscriptionStatus {
+ active,
+ trialing,
+ pastDue,
+ canceled,
+ expired,
+ paused,
+ unknown;
+
+ /// Parses the server's snake_case status.
+ static SubscriptionStatus parse(String value) => switch (value) {
+ 'active' => active,
+ 'trialing' => trialing,
+ 'past_due' => pastDue,
+ 'canceled' => canceled,
+ 'expired' => expired,
+ 'paused' => paused,
+ _ => unknown,
+ };
+}
+
+/// Currencies the ledger stores without a minor unit. It stores every other
+/// currency with two places, so this list has to match the ledger's, not ISO 4217.
+const _zeroDecimal = {'jpy', 'krw', 'vnd', 'clp', 'pyg', 'idr'};
+
+const _symbols = {'usd': r'$', 'eur': '€', 'gbp': '£', 'jpy': '¥', 'inr': '₹', 'cad': r'CA$', 'aud': r'A$'};
+
+/// An amount in the currency's minor unit (cents for usd).
+class Money {
+ const Money({required this.amountMinor, required this.currency});
+
+ final int amountMinor;
+ final String currency;
+
+ /// Decimal places the currency uses.
+ int get decimals => _zeroDecimal.contains(currency.toLowerCase()) ? 0 : 2;
+
+ /// The amount in major units, e.g. 29.0 for 2900 usd cents.
+ double get major => decimals == 0 ? amountMinor.toDouble() : amountMinor / 100;
+
+ /// A plain rendering such as `$29.00` or `NGN 10.50`. Use your own
+ /// NumberFormat for locale-aware output; [major] and [currency] are there
+ /// for that.
+ String format() {
+ // Integer arithmetic, so a large amount doesn't pick up float rounding.
+ final abs = amountMinor.abs();
+ final amount = decimals == 0
+ ? '$abs'
+ : '${abs ~/ 100}.${(abs % 100).toString().padLeft(2, '0')}';
+ final sign = amountMinor < 0 ? '-' : '';
+ final symbol = _symbols[currency.toLowerCase()];
+ return symbol != null ? '$sign$symbol$amount' : '$sign${currency.toUpperCase()} $amount';
+ }
+}
+
+/// A plan's base price and how often it's charged.
+class Price extends Money {
+ const Price({required super.amountMinor, required super.currency, required this.interval});
+
+ /// "monthly", "yearly", ... as the server names it.
+ final String interval;
+}
+
+/// One feature a plan includes. A null [limit] means unlimited.
+class PlanFeature {
+ const PlanFeature({
+ required this.key,
+ required this.name,
+ required this.type,
+ required this.limit,
+ required this.softLimit,
+ this.period,
+ });
+
+ final String key;
+ final String name;
+
+ /// "boolean", "metered" or "seat".
+ final String type;
+ final int? limit;
+ final String? period;
+ final bool softLimit;
+
+ factory PlanFeature.fromJson(Map json) => PlanFeature(
+ key: json['key'] as String,
+ name: json['name'] as String,
+ type: json['type'] as String,
+ limit: (json['limit'] as num?)?.toInt(),
+ period: json['period'] as String?,
+ softLimit: json['soft_limit'] as bool? ?? false,
+ );
+}
+
+class Plan {
+ const Plan({
+ required this.id,
+ required this.slug,
+ required this.name,
+ required this.description,
+ required this.status,
+ required this.trialDays,
+ required this.isAddon,
+ required this.price,
+ required this.features,
+ required this.metadata,
+ });
+
+ final String id;
+ final String slug;
+ final String name;
+ final String description;
+ final String status;
+ final int trialDays;
+ final bool isAddon;
+
+ /// Null for a plan without a price.
+ final Price? price;
+ final List features;
+ final Map metadata;
+
+ factory Plan.fromJson(Map json) {
+ final amount = json['amount_minor'] as num?;
+ return Plan(
+ id: json['id'] as String,
+ slug: json['slug'] as String,
+ name: json['name'] as String,
+ description: json['description'] as String? ?? '',
+ status: json['status'] as String,
+ trialDays: (json['trial_days'] as num?)?.toInt() ?? 0,
+ isAddon: json['is_addon'] as bool? ?? false,
+ price: amount == null
+ ? null
+ : Price(
+ amountMinor: amount.toInt(),
+ currency: json['currency'] as String,
+ interval: json['billing_period'] as String? ?? '',
+ ),
+ features: [
+ for (final f in (json['features'] as List? ?? const []))
+ PlanFeature.fromJson(Map.from(f as Map)),
+ ],
+ metadata: Map.from(json['metadata'] as Map? ?? const {}),
+ );
+ }
+}
+
+/// Seat use on a plan with a seat feature. A null [limit] means unlimited.
+class Seats {
+ const Seats({required this.featureKey, required this.used, required this.limit});
+
+ final String featureKey;
+ final int used;
+ final int? limit;
+}
+
+class Subscription {
+ const Subscription({
+ required this.id,
+ required this.tenantId,
+ required this.status,
+ required this.plan,
+ required this.currentPeriodStart,
+ required this.currentPeriodEnd,
+ this.trialStart,
+ this.trialEnd,
+ this.cancelAt,
+ this.canceledAt,
+ this.pausedAt,
+ this.seats,
+ });
+
+ final String id;
+ final String tenantId;
+ final SubscriptionStatus status;
+ final Plan plan;
+ final DateTime currentPeriodStart;
+ final DateTime currentPeriodEnd;
+ final DateTime? trialStart;
+ final DateTime? trialEnd;
+
+ /// When a cancellation scheduled for the period end takes effect.
+ final DateTime? cancelAt;
+ final DateTime? canceledAt;
+ final DateTime? pausedAt;
+ final Seats? seats;
+
+ /// The tenant has the plan's features: active, trialing, or past due.
+ bool get isActive =>
+ status == SubscriptionStatus.active ||
+ status == SubscriptionStatus.trialing ||
+ status == SubscriptionStatus.pastDue;
+ bool get isTrialing => status == SubscriptionStatus.trialing;
+ bool get isPaused => status == SubscriptionStatus.paused;
+
+ /// A cancellation is scheduled; the plan runs until [cancelAt].
+ bool get willCancel => cancelAt != null;
+
+ /// Whole days of trial left, null when not trialing.
+ int? get trialDaysLeft {
+ final end = trialEnd;
+ if (!isTrialing || end == null) return null;
+ final left = end.difference(DateTime.now()).inMilliseconds / Duration.millisecondsPerDay;
+ return left <= 0 ? 0 : left.ceil();
+ }
+
+ factory Subscription.fromJson(Map json) {
+ DateTime? date(String key) {
+ final v = json[key] as String?;
+ return v == null ? null : DateTime.parse(v);
+ }
+
+ final seats = json['seats'] as Map?;
+ return Subscription(
+ id: json['id'] as String,
+ tenantId: json['tenant_id'] as String,
+ status: SubscriptionStatus.parse(json['status'] as String),
+ plan: Plan.fromJson(Map.from(json['plan'] as Map)),
+ currentPeriodStart: DateTime.parse(json['current_period_start'] as String),
+ currentPeriodEnd: DateTime.parse(json['current_period_end'] as String),
+ trialStart: date('trial_start'),
+ trialEnd: date('trial_end'),
+ cancelAt: date('cancel_at'),
+ canceledAt: date('canceled_at'),
+ pausedAt: date('paused_at'),
+ seats: seats == null
+ ? null
+ : Seats(
+ featureKey: seats['feature_key'] as String,
+ used: (seats['used'] as num).toInt(),
+ limit: (seats['limit'] as num?)?.toInt(),
+ ),
+ );
+ }
+}
+
+/// Whether the tenant may use a feature, and how much of it is left.
+class Entitlement {
+ const Entitlement({
+ required this.allowed,
+ required this.used,
+ required this.limit,
+ required this.remaining,
+ this.reason,
+ });
+
+ final bool allowed;
+ final int used;
+
+ /// Negative means unlimited, as ledger reports it.
+ final int limit;
+ final int remaining;
+ final String? reason;
+
+ factory Entitlement.fromJson(Map json) => Entitlement(
+ allowed: json['allowed'] as bool,
+ used: (json['used'] as num).toInt(),
+ limit: (json['limit'] as num).toInt(),
+ remaining: (json['remaining'] as num).toInt(),
+ reason: json['reason'] as String?,
+ );
+}
diff --git a/flutter/packages/authsome_subscription/lib/src/subscription_api.dart b/flutter/packages/authsome_subscription/lib/src/subscription_api.dart
new file mode 100644
index 00000000..7fd6d6fc
--- /dev/null
+++ b/flutter/packages/authsome_subscription/lib/src/subscription_api.dart
@@ -0,0 +1,377 @@
+import 'dart:async';
+
+import 'package:authsome_core/authsome_core.dart';
+
+import 'models.dart';
+
+/// Where a piece of server state is. [unavailable] means the server doesn't
+/// have the subscription plugin installed; treat it like a plan without
+/// the feature.
+enum ResourceStatus { idle, loading, ready, error, unavailable }
+
+/// One piece of server state.
+class Resource {
+ const Resource(this.status, this.data, [this.error]);
+
+ final ResourceStatus status;
+ final T data;
+ final Object? error;
+
+ bool get isLoading => status == ResourceStatus.loading;
+}
+
+/// Everything the plugin knows, as one immutable snapshot.
+class SubscriptionSnapshot {
+ const SubscriptionSnapshot({
+ required this.subscription,
+ required this.canManage,
+ required this.plans,
+ required this.entitlements,
+ });
+
+ SubscriptionSnapshot.initial()
+ : subscription = const Resource(ResourceStatus.idle, null),
+ canManage = false,
+ plans = const Resource(ResourceStatus.idle, []),
+ entitlements = const Resource(ResourceStatus.idle, {});
+
+ /// The tenant's subscription; data is null when it has none.
+ final Resource subscription;
+
+ /// Whether the signed-in user may change the subscription.
+ final bool canManage;
+
+ /// Plans the tenant can buy, cheapest first.
+ final Resource> plans;
+
+ /// Every feature on the current plan, checked.
+ final Resource