Skip to content

feat: add the subscription client plugin for React and Flutter - #169

Merged
juicycleff merged 12 commits into
feat/client-plugin-contractfrom
feat/subscription-client-plugin
Nov 10, 2026
Merged

juicycleff merged 12 commits into
feat/client-plugin-contractfrom
feat/subscription-client-plugin

Conversation

@juicycleff

Copy link
Copy Markdown
Contributor

Billing gets its own client packages, built on the plugin contract from #168. You install them next to the auth packages, and the subscription calls leave core.

This is the last of three PRs, after #167 (the server routes) and #168 (the plugin contract), and it's based on #168's branch.

Using it

import { subscription } from "@authsome/plugin-subscription";
import { useSubscription, Entitled } from "@authsome/plugin-subscription/react";
import { PricingTable, CurrentPlanCard } from "@authsome/plugin-subscription/components";

<AuthProvider baseURL={url} plugins={[subscription()]}>
  <PricingTable highlight="team" />
  <Entitled feature="sso" fallback={<UpgradePrompt />}><SsoSettings /></Entitled>
</AuthProvider>
AuthProvider(
  config: AuthConfig(baseUrl: url, plugins: const [SubscriptionPlugin()]),
  child: const MyApp(),
);

PricingTable(highlight: 'team', onCheckout: (uri) => launchUrl(uri));
const EntitlementGate(feature: 'sso', fallback: UpgradePrompt(), child: SsoSettings());

Three packages:

  • @authsome/plugin-subscription, with . (framework-free), ./react (hooks) and ./components (styled like @authsome/ui-components). Only the React entries are marked "use client", so the plugin and its models import fine from server components.
  • authsome_subscription, pure Dart.
  • authsome_subscription_flutter, the widgets. They hand checkout and portal URLs back through onCheckout and onOpen, so neither package pulls in url_launcher or intl.

Each one caches the tenant's subscription, entitlements and plans, and follows the session. Sign out and the cache empties. Switch organization and the old org's plan, buttons and entitlements disappear while the new one loads, so nobody upgrades the wrong org from a stale screen. A write that lands after you switched or signed out still returns its result to the caller, but stays out of the cache. Overlapping loads share one request.

Gating fails closed. Entitled and EntitlementGate show the fallback while loading, on an error, and when the server doesn't have the plugin installed.

Deprecations in core

The billing methods on the embedded ui-core and authsome_core clients still work, and are now marked deprecated. sdkgen gained --deprecate-tags, and rules match on the longest tag prefix:

  • The tenant routes (tag Billing) point at the new packages.
  • The operator routes (Billing Plans, Billing Usage and so on) say to call them from a server with @authsome/client or the Dart SDK.

The standalone SDKs keep everything undeprecated, since operators call those routes from servers.

Releasing

The release workflow publishes all three packages, and the version bump script covers the two Dart ones. Trusted publishing on npm and automated publishing on pub.dev can only be turned on for a package that already exists, so you'll need to publish each of the three by hand once (npm publish for the npm package, dart pub publish for the two Dart packages), then set up trusted publishing. Until then their release steps fail. Comments in release.yml say the same.

Calls we made

  • Prices follow the ledger's currency digits (jpy, krw, vnd, clp, pyg and idr have none, everything else has two), not ISO 4217. Amounts have to round-trip with the server, and idr is the one place the two disagree.
  • The components show plain copy ("Couldn't change your plan. Try again.") rather than the server's message, which is written for developers. onError gets the real error.
  • A Flutter PricingTable without onCheckout can't take payment. It tells the user and hands onError a StateError instead of failing silently.

Not in here

  • Cancel plan has no confirmation step yet.
  • sdkgen doesn't escape notes, so a note can't contain a semicolon, or a quote on the Dart side. The Makefile says so.
  • No dev-mode warning when the server lacks the subscription plugin, and no tenantId override for operators using the plugin.

Checked

GOWORK=off go test ./... passes. In ui, every package builds, type-checks and lints, and the tests pass (core 173, react 25, components 102, nextjs 32, plugin-subscription 42). Flutter: authsome_core 67, authsome_flutter 8, authsome_flutter_ui 86, authsome_subscription 21, authsome_subscription_flutter 14, all passing, and dart analyze is clean apart from one existing info in the generated client. Regenerating the clients shows no drift, and dart pub publish --dry-run is clean for both Dart packages.

A new package, @authsome/plugin-subscription, built only on the client
plugin contract. Register it with plugins: [subscription()] and it gives
you one cached snapshot of the tenant's subscription, whether you may
change it, its entitlements and the plans you can buy, as typed camelCase
models with derived flags (isActive, willCancel, trialDaysLeft).

Writes return the new subscription and update the cache, or a checkout
URL that leaves it alone. It reloads on sign-in and organization switch,
forgets everything on sign-out, drops responses for an organization the
session has left, and reports unavailable without calling the server when
the server has no subscription plugin.
useSubscription() returns the subscription, its plan, can_manage and
every write, loading on first use and tracking writes in flight.
usePlans() and useEntitlement(feature) do the same for plans and feature
checks. <Entitled feature> shows its children only on a positive answer:
loading, an error or billing not being installed all show the fallback.
…ter and ManageBillingButton

Styled components on @authsome/ui-components primitives, exported from
@authsome/plugin-subscription/components. PricingTable subscribes or
changes plan and hands a checkout URL to onCheckout (redirecting by
default). CurrentPlanCard shows when the plan renews, ends or leaves
trial, seats, and cancel or keep. Buttons only appear for someone who
can manage billing.
The Dart half of @authsome/plugin-subscription. Add SubscriptionPlugin()
to AuthConfig.plugins and auth.plugin<SubscriptionApi>() gives you a
snapshot and a change stream of the tenant's subscription, can_manage,
entitlements and plans, as typed models (SubscriptionStatus enum,
DateTime fields, Money with major units and a plain formatter).

Writes return a sealed ChangeResult: SubscriptionChanged with the new
subscription in the cache, or CheckoutRequired with the payment URL. It
follows sign-in, sign-out and organization switches the same way the TS
plugin does.
…cription plugin

context.subscription reaches the plugin, SubscriptionBuilder rebuilds
from its snapshot and loads on first build, and EntitlementGate shows
its child only on a positive answer. PricingTable, CurrentPlanCard,
UsageMeter and ManageBillingButton are Material 3 widgets. Opening a
checkout or portal URL is left to the app (onCheckout, onOpen), so the
package doesn't pull in a URL launcher.
A --deprecate-tags "Prefix=note" rule adds @deprecated (TS) or
@deprecated (Dart) to every operation whose tag starts with the prefix.
The embedded ui-core and authsome_core clients use it to point billing
callers at the subscription plugin packages. The standalone SDKs keep
the billing methods as they are, since operators call those routes from
servers.
The release workflow publishes @authsome/plugin-subscription to npm and
authsome_subscription and authsome_subscription_flutter to pub.dev with
the rest, and the version scripts stamp all three. Trusted publishing
needs a package to exist first, so each one's first release is manual.

Also adds READMEs and changelogs for the three packages, and React and
Flutter sections to the subscription plugin's docs page.
… components

A write that lands after an org switch or sign-out no longer overwrites the new tenant's data, and overlapping loads share one request. Prices follow the ledger's currency decimals. The components show plain error copy, mark the highlighted plan in words, and describe unlimited meters to screen readers. Only the React entries carry the use client banner now.
…rrencies

The Dart plugin now matches the TypeScript one: a write or plan list that lands after an org switch or sign-out stays out of the cache, overlapping loads share one request, and an org switch clears the old org's data while the new one loads. Money uses the ledger's zero-decimal list and formats with integer arithmetic. The Flutter widgets stop spinning when nobody is signed in, show plain error copy with an onError hook, report a missing onCheckout, and label the highlighted plan.
Deprecation rules now match on the longest tag prefix, so the operator groups (Billing Plans, Billing Usage and the rest) get their own note telling you to call them from a server, while the tenant Billing routes keep pointing at the subscription plugin. The embedded ui-core and authsome_core clients are regenerated.
@juicycleff
juicycleff merged commit c7d2552 into feat/client-plugin-contract Nov 10, 2026
@juicycleff
juicycleff deleted the feat/subscription-client-plugin branch November 10, 2026 16:55
juicycleff added a commit that referenced this pull request Nov 10, 2026
* feat(api): report the session's active organization on /v1/me

GET /v1/me now carries active_org_id, the organization the session has
switched into, read from the session or, for a JWT, from the token's
claims. Clients use it to restore the active organization on reload.

* chore(sdk): regenerate clients for active_org_id

* feat(ui-core): add a client plugin contract and track the active organization

Optional features can now ship as client plugins, the way they ship as
server plugins. Pass plugins: [subscription()] to AuthManager and look
one up with manager.plugin(subscription). A plugin gets a small context:
an authenticated request (token or cookie), the auth state and client
config, whether the server has it installed, the active organization,
and signedIn, signedOut, sessionRefreshed and orgChanged events.

AuthManager also keeps the session's active organization. It's restored
from /v1/me, set by switchOrg (which keeps a reissued token), and
forgotten on sign-out.

* feat(ui-react)!: add usePlugin and useActiveOrg; make Protect fail closed

usePlugin(subscription) returns a registered client plugin's API, and
useActiveOrg() the session's organization with a switchOrg that
re-renders on change. AuthProvider takes plugins through its config.

BREAKING CHANGE: <Protect role> and <Protect permission> used to render
their children for everyone. role now checks the roles /v1/me returns.
permission can't be checked on the client, so without a condition that
checks it Protect shows the fallback and warns once. Pass a condition to
keep a permission-gated Protect working.

* feat(authsome_core): add a client plugin contract and track the active organization

The Dart side of the ui-core change. Pass plugins: [SomePlugin()] in
AuthConfig and look one up with manager.plugin<SomeApi>(). A plugin gets
a PluginContext with an authenticated request, the auth state, the
client config, whether the server has it installed, the active
organization and an events stream.

AuthManager keeps the session's active organization from /v1/me,
switchOrg changes it (keeping a reissued token), and sign-out clears it.
AuthSomeClient gains send(), the raw request plugins use.

* feat(flutter,ui): expose plugins and the active organization in the bindings

AuthNotifier passes the configured plugins through to AuthManager (it
used to rebuild the config field by field and would have dropped them),
and gains plugin<Api>(), activeOrgId and switchOrg, notifying on an
organization switch.

Both OrgSwitchers now work on their own: without activeOrgId and
onOrgChange they show the session's active organization and switch it.
Passing the props keeps the old controlled behaviour. The Flutter
switcher's create row also stops overflowing when its label is long.

* docs(ui): document client plugins and the active organization

* fix(ui-core,authsome_core): harden org switching and plugin events

switchOrg now runs in turn with token refreshes and merges a reissued
access token into the latest stored session, so a refresh that rotated
the refresh token meanwhile isn't undone. It also refuses to restore a
session that signed out, or became someone else's, while it was waiting.

Signing in as a different user without signing out first fires
signedOut and signedIn and drops the previous user's organization. In TS
a second switch no longer replays the org the session was loaded with.
destroy() keeps plugin listeners, so plugins keep hearing events after a
StrictMode remount. Switching to no organization leaves null, not "".

definePlugin types required options as required. Dart's plugin<Api>()
refuses a call without a type argument and an API two plugins share.
OrgSwitcher shows a message (and calls onError) when a switch fails, and
is disabled while one is in flight. The React guide documents Protect
and what changed about role and permission.

* test(api): cover /v1/me's active org from a JWT's claims

* feat: add the subscription client plugin for React and Flutter (#169)

* feat(plugin-subscription): add the subscription client plugin

A new package, @authsome/plugin-subscription, built only on the client
plugin contract. Register it with plugins: [subscription()] and it gives
you one cached snapshot of the tenant's subscription, whether you may
change it, its entitlements and the plans you can buy, as typed camelCase
models with derived flags (isActive, willCancel, trialDaysLeft).

Writes return the new subscription and update the cache, or a checkout
URL that leaves it alone. It reloads on sign-in and organization switch,
forgets everything on sign-out, drops responses for an organization the
session has left, and reports unavailable without calling the server when
the server has no subscription plugin.

* feat(plugin-subscription): add React hooks and the Entitled gate

useSubscription() returns the subscription, its plan, can_manage and
every write, loading on first use and tracking writes in flight.
usePlans() and useEntitlement(feature) do the same for plans and feature
checks. <Entitled feature> shows its children only on a positive answer:
loading, an error or billing not being installed all show the fallback.

* feat(plugin-subscription): add PricingTable, CurrentPlanCard, UsageMeter and ManageBillingButton

Styled components on @authsome/ui-components primitives, exported from
@authsome/plugin-subscription/components. PricingTable subscribes or
changes plan and hands a checkout URL to onCheckout (redirecting by
default). CurrentPlanCard shows when the plan renews, ends or leaves
trial, seats, and cancel or keep. Buttons only appear for someone who
can manage billing.

* feat(authsome_subscription): add the subscription client plugin for Dart

The Dart half of @authsome/plugin-subscription. Add SubscriptionPlugin()
to AuthConfig.plugins and auth.plugin<SubscriptionApi>() gives you a
snapshot and a change stream of the tenant's subscription, can_manage,
entitlements and plans, as typed models (SubscriptionStatus enum,
DateTime fields, Money with major units and a plain formatter).

Writes return a sealed ChangeResult: SubscriptionChanged with the new
subscription in the cache, or CheckoutRequired with the payment URL. It
follows sign-in, sign-out and organization switches the same way the TS
plugin does.

* feat(authsome_subscription_flutter): add Flutter widgets for the subscription plugin

context.subscription reaches the plugin, SubscriptionBuilder rebuilds
from its snapshot and loads on first build, and EntitlementGate shows
its child only on a positive answer. PricingTable, CurrentPlanCard,
UsageMeter and ManageBillingButton are Material 3 widgets. Opening a
checkout or portal URL is left to the app (onCheckout, onOpen), so the
package doesn't pull in a URL launcher.

* feat(sdkgen): mark tagged operations deprecated in generated clients

A --deprecate-tags "Prefix=note" rule adds @deprecated (TS) or
@deprecated (Dart) to every operation whose tag starts with the prefix.
The embedded ui-core and authsome_core clients use it to point billing
callers at the subscription plugin packages. The standalone SDKs keep
the billing methods as they are, since operators call those routes from
servers.

* chore(sdk): deprecate the billing methods on the embedded core clients

* chore(release): publish the subscription client plugins

The release workflow publishes @authsome/plugin-subscription to npm and
authsome_subscription and authsome_subscription_flutter to pub.dev with
the rest, and the version scripts stamp all three. Trusted publishing
needs a package to exist first, so each one's first release is manual.

Also adds READMEs and changelogs for the three packages, and React and
Flutter sections to the subscription plugin's docs page.

* fix(plugin-subscription): drop stale writes, share loads and tidy the components

A write that lands after an org switch or sign-out no longer overwrites the new tenant's data, and overlapping loads share one request. Prices follow the ledger's currency decimals. The components show plain error copy, mark the highlighted plan in words, and describe unlimited meters to screen readers. Only the React entries carry the use client banner now.

* fix(authsome_subscription): drop stale writes and match the ledger currencies

The Dart plugin now matches the TypeScript one: a write or plan list that lands after an org switch or sign-out stays out of the cache, overlapping loads share one request, and an org switch clears the old org's data while the new one loads. Money uses the ledger's zero-decimal list and formats with integer arithmetic. The Flutter widgets stop spinning when nobody is signed in, show plain error copy with an onError hook, report a missing onCheckout, and label the highlighted plan.

* fix(sdkgen): point operator billing routes at server-side use

Deprecation rules now match on the longest tag prefix, so the operator groups (Billing Plans, Billing Usage and the rest) get their own note telling you to call them from a server, while the tenant Billing routes keep pointing at the subscription plugin. The embedded ui-core and authsome_core clients are regenerated.

* ci(release): note the manual first pub.dev publish

* style(sdkgen): gofmt the deprecation tests

* feat: add tenant override, cancel confirmation and plugin warning to subscription clients (#172)

* feat: confirm before canceling a plan

CurrentPlanCard asks before it cancels, on both platforms, and says when the plan ends. React does it inline since ui-components has no dialog; Flutter uses an AlertDialog.

* fix(sdkgen): escape deprecation notes for each language

A Dart note is escaped for its single-quoted annotation, and a TypeScript one can no longer close its JSDoc block with */. The --deprecate-tags parser moves into openapi.ParseDeprecationRules and takes \; for a semicolon inside a note.

* feat(plugin-subscription): add a tenant override and warn without a server plugin

api.forTenant(id) returns the same API pinned to another tenant, with its own cache, for operators who hold billing:manage. It keeps its tenant across org switches and clears on sign-out. The hooks and components take a tenantId. When the server doesn't have the subscription plugin, the client now says so once in the console (not in production) instead of quietly reading as unavailable.

* feat(authsome_subscription): add a tenant override and warn without a server plugin

The Dart plugin gets forTenant(id) like the TypeScript one, and debug builds print one warning when the server doesn't have the subscription plugin. Every Flutter widget takes a tenantId, and context.subscriptionFor(id) returns that tenant's API. The docs cover the override, the warning and the cancel confirmation.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant