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