From a634db0375f2a0c5ff7f463a564e77a933601496 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Tue, 10 Nov 2026 10:04:29 -0600 Subject: [PATCH 01/12] 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. --- ui/packages/plugin-subscription/package.json | 79 +++++ ui/packages/plugin-subscription/src/index.ts | 36 +++ ui/packages/plugin-subscription/src/models.ts | 222 +++++++++++++++ .../plugin-subscription/src/plugin.test.ts | 237 +++++++++++++++ ui/packages/plugin-subscription/src/plugin.ts | 269 ++++++++++++++++++ ui/packages/plugin-subscription/tsconfig.json | 20 ++ .../plugin-subscription/tsup.config.ts | 23 ++ .../plugin-subscription/vitest.config.ts | 16 ++ ui/pnpm-lock.yaml | 43 +++ 9 files changed, 945 insertions(+) create mode 100644 ui/packages/plugin-subscription/package.json create mode 100644 ui/packages/plugin-subscription/src/index.ts create mode 100644 ui/packages/plugin-subscription/src/models.ts create mode 100644 ui/packages/plugin-subscription/src/plugin.test.ts create mode 100644 ui/packages/plugin-subscription/src/plugin.ts create mode 100644 ui/packages/plugin-subscription/tsconfig.json create mode 100644 ui/packages/plugin-subscription/tsup.config.ts create mode 100644 ui/packages/plugin-subscription/vitest.config.ts diff --git a/ui/packages/plugin-subscription/package.json b/ui/packages/plugin-subscription/package.json new file mode 100644 index 00000000..f3d02ecb --- /dev/null +++ b/ui/packages/plugin-subscription/package.json @@ -0,0 +1,79 @@ +{ + "name": "@authsome/plugin-subscription", + "version": "1.5.5", + "description": "Client plugin for authsome's subscription plugin: plans, subscriptions, seats and entitlements, with React hooks and components", + "license": "MIT", + "type": "module", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "https://github.com/xraph/authsome.git", + "directory": "ui/packages/plugin-subscription" + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "./react": { + "import": "./dist/react.js", + "types": "./dist/react.d.ts" + }, + "./components": { + "import": "./dist/components.js", + "types": "./dist/components.d.ts" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "typecheck": "tsc --noEmit", + "clean": "rm -rf dist", + "lint": "eslint .", + "test": "vitest run" + }, + "dependencies": { + "@authsome/ui-core": "workspace:*" + }, + "peerDependencies": { + "@authsome/ui-components": "workspace:*", + "@authsome/ui-react": "workspace:*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@authsome/ui-components": { + "optional": true + }, + "@authsome/ui-react": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + }, + "devDependencies": { + "@authsome/ui-components": "workspace:*", + "@authsome/ui-react": "workspace:*", + "@testing-library/react": "^16.3.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.5", + "jsdom": "^28.0.1", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "tsup": "^8.5.1", + "typescript": "^5.9.3", + "vite": "^8.2.4", + "vitest": "^4.1.12" + } +} diff --git a/ui/packages/plugin-subscription/src/index.ts b/ui/packages/plugin-subscription/src/index.ts new file mode 100644 index 00000000..6a1ed80f --- /dev/null +++ b/ui/packages/plugin-subscription/src/index.ts @@ -0,0 +1,36 @@ +/** + * @authsome/plugin-subscription — the client half of authsome's + * subscription plugin. + * + * ```ts + * import { subscription } from "@authsome/plugin-subscription"; + * + * const auth = new AuthManager({ baseURL, plugins: [subscription()] }); + * const billing = auth.plugin(subscription); + * await billing.load(); + * ``` + * + * React hooks live in "@authsome/plugin-subscription/react" and styled + * components in "@authsome/plugin-subscription/components". + */ + +export { + subscription, + createSubscriptionApi, + type ChangeOptions, + type ChangeResult, + type Resource, + type SubscriptionApi, + type SubscriptionSnapshot, +} from "./plugin"; +export { + formatMoney, + type Entitlement, + type Money, + type Plan, + type PlanFeature, + type Price, + type Seats, + type Subscription, + type SubscriptionStatus, +} from "./models"; diff --git a/ui/packages/plugin-subscription/src/models.ts b/ui/packages/plugin-subscription/src/models.ts new file mode 100644 index 00000000..b8f59a9b --- /dev/null +++ b/ui/packages/plugin-subscription/src/models.ts @@ -0,0 +1,222 @@ +/** + * Typed models for the subscription plugin, mapped from the server's JSON. + * Field names are camelCase; times stay RFC 3339 strings, so they survive + * serialization (server components, caches) unchanged. + */ + +/** A subscription's state. Unknown values from a newer server pass through. */ +export type SubscriptionStatus = + | "active" + | "trialing" + | "past_due" + | "canceled" + | "expired" + | "paused" + | (string & {}); + +/** An amount in the currency's minor unit (cents for usd). */ +export interface Money { + amountMinor: number; + currency: string; +} + +/** A plan's base price and how often it's charged. */ +export interface Price extends Money { + /** "monthly", "yearly", ... as the server names it. */ + interval: string; +} + +/** One feature a plan includes. A null limit means unlimited. */ +export interface PlanFeature { + key: string; + name: string; + /** "boolean", "metered" or "seat". */ + type: string; + limit: number | null; + period?: string; + softLimit: boolean; +} + +export interface Plan { + id: string; + slug: string; + name: string; + description: string; + status: string; + trialDays: number; + isAddon: boolean; + /** Null for a plan without a price. */ + price: Price | null; + features: PlanFeature[]; + metadata: Record; +} + +/** Seat use on a plan with a seat feature. A null limit means unlimited. */ +export interface Seats { + featureKey: string; + used: number; + limit: number | null; +} + +export interface Subscription { + id: string; + tenantId: string; + status: SubscriptionStatus; + plan: Plan; + currentPeriodStart: string; + currentPeriodEnd: string; + trialStart: string | null; + trialEnd: string | null; + /** When a cancellation scheduled for the period end takes effect. */ + cancelAt: string | null; + canceledAt: string | null; + pausedAt: string | null; + seats: Seats | null; + /** The tenant has the plan's features: active, trialing, or past due. */ + isActive: boolean; + isTrialing: boolean; + isPaused: boolean; + /** A cancellation is scheduled; the plan runs until cancelAt. */ + willCancel: boolean; + /** Whole days of trial left, null when not trialing. */ + trialDaysLeft: number | null; +} + +/** Whether the tenant may use a feature, and how much of it is left. */ +export interface Entitlement { + allowed: boolean; + used: number; + /** -1 or a large number from the server means unlimited, as ledger reports it. */ + limit: number; + remaining: number; + reason?: string; +} + +// ── Mapping from the server's JSON ──────────────── + +interface PlanFeatureJSON { + key: string; + name: string; + type: string; + limit: number | null; + period?: string; + soft_limit?: boolean; +} + +export interface PlanJSON { + id: string; + slug: string; + name: string; + description?: string; + status: string; + trial_days?: number; + is_addon?: boolean; + currency: string; + amount_minor?: number; + billing_period?: string; + features?: PlanFeatureJSON[]; + metadata?: Record; +} + +export interface SubscriptionJSON { + id: string; + tenant_id: string; + status: string; + plan: PlanJSON; + current_period_start: string; + current_period_end: string; + trial_start?: string | null; + trial_end?: string | null; + cancel_at?: string | null; + canceled_at?: string | null; + paused_at?: string | null; + seats?: { feature_key: string; used: number; limit: number | null } | null; +} + +export interface EntitlementJSON { + allowed: boolean; + used: number; + limit: number; + remaining: number; + reason?: string; +} + +const DAY_MS = 86_400_000; + +export function toPlan(p: PlanJSON): Plan { + return { + id: p.id, + slug: p.slug, + name: p.name, + description: p.description ?? "", + status: p.status, + trialDays: p.trial_days ?? 0, + isAddon: p.is_addon ?? false, + price: + p.amount_minor === undefined || p.amount_minor === null + ? null + : { amountMinor: p.amount_minor, currency: p.currency, interval: p.billing_period ?? "" }, + features: (p.features ?? []).map((f) => ({ + key: f.key, + name: f.name, + type: f.type, + limit: f.limit ?? null, + ...(f.period ? { period: f.period } : {}), + softLimit: f.soft_limit ?? false, + })), + metadata: p.metadata ?? {}, + }; +} + +export function toSubscription(s: SubscriptionJSON, now = Date.now()): Subscription { + const trialEnd = s.trial_end ?? null; + const cancelAt = s.cancel_at ?? null; + const trialing = s.status === "trialing"; + return { + id: s.id, + tenantId: s.tenant_id, + status: s.status, + plan: toPlan(s.plan), + currentPeriodStart: s.current_period_start, + currentPeriodEnd: s.current_period_end, + trialStart: s.trial_start ?? null, + trialEnd, + cancelAt, + canceledAt: s.canceled_at ?? null, + pausedAt: s.paused_at ?? null, + seats: s.seats + ? { featureKey: s.seats.feature_key, used: s.seats.used, limit: s.seats.limit ?? null } + : null, + isActive: s.status === "active" || trialing || s.status === "past_due", + isTrialing: trialing, + isPaused: s.status === "paused", + willCancel: cancelAt !== null, + trialDaysLeft: + trialing && trialEnd + ? Math.max(0, Math.ceil((Date.parse(trialEnd) - now) / DAY_MS)) + : null, + }; +} + +export function toEntitlement(e: EntitlementJSON): Entitlement { + return { + allowed: e.allowed, + used: e.used, + limit: e.limit, + remaining: e.remaining, + ...(e.reason ? { reason: e.reason } : {}), + }; +} + +/** + * Formats an amount for display, dividing by the currency's own number of + * decimal places (2 for usd, 0 for jpy). + */ +export function formatMoney(money: Money, locale?: string): string { + const format = new Intl.NumberFormat(locale, { + style: "currency", + currency: money.currency.toUpperCase(), + }); + const digits = format.resolvedOptions().maximumFractionDigits ?? 2; + return format.format(money.amountMinor / 10 ** digits); +} diff --git a/ui/packages/plugin-subscription/src/plugin.test.ts b/ui/packages/plugin-subscription/src/plugin.test.ts new file mode 100644 index 00000000..917bef80 --- /dev/null +++ b/ui/packages/plugin-subscription/src/plugin.test.ts @@ -0,0 +1,237 @@ +import { AuthManager, type ClientConfig, type Session } from "@authsome/ui-core"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { formatMoney, subscription, type Subscription } from "./index"; + +const managers: AuthManager[] = []; +afterEach(() => managers.splice(0).forEach((m) => m.destroy())); +const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status }); + +const inAnHour = () => new Date(Date.now() + 3_600_000).toISOString(); + +export function planJSON(slug: string, overrides: Record = {}) { + return { + id: `plan_${slug}`, name: slug[0].toUpperCase() + slug.slice(1), slug, description: "", + currency: "usd", status: "active", trial_days: 0, features_count: 1, is_addon: false, app_id: "app_1", + base_amount: "29.00", billing_period: "monthly", amount_minor: 2900, + features: [{ key: "sso", name: "SSO", type: "boolean", limit: 1, soft_limit: false }], + ...overrides, + }; +} + +export function subJSON(overrides: Record = {}) { + return { + id: "sub_1", tenant_id: "org_1", status: "active", plan: planJSON("pro"), + current_period_start: "2026-11-01T00:00:00Z", current_period_end: "2026-12-01T00:00:00Z", + trial_start: null, trial_end: null, cancel_at: null, canceled_at: null, paused_at: null, + app_id: "app_1", + ...overrides, + }; +} + +type Handler = (init?: RequestInit, url?: URL) => Response | Promise; + +function setup(opts: { serverPlugins?: string[]; me?: Record; appId?: string } = {}) { + const session: Session = { session_token: "tok", refresh_token: "ref", expires_at: inAnHour() }; + const store = new Map([["authsome:session", JSON.stringify(session)]]); + const routes = new Map([ + ["GET /v1/me", () => json({ id: "usr_1", email: "a@test", app_id: "app_user", active_org_id: "org_1", ...opts.me })], + ["POST /v1/signout", () => json({ status: "ok" })], + ["GET /v1/billing/subscription", () => json({ subscription: subJSON(), can_manage: true })], + ["GET /v1/billing/entitlements", () => json({ entitlements: { sso: { allowed: true, feature: "sso", used: 0, limit: 1, remaining: 1 } } })], + ]); + const calls: string[] = []; + const fetch = vi.fn(async (input, init) => { + const url = new URL(String(input)); + const key = `${init?.method ?? "GET"} ${url.pathname}`; + calls.push(`${key}${url.search}`); + const handler = routes.get(key); + if (!handler) return json({ error: `no route ${key}` }, 404); + return handler(init, url); + }); + const initialClientConfig = opts.serverPlugins + ? ({ supported_plugins: opts.serverPlugins, app_id: opts.appId } as unknown as ClientConfig) + : opts.appId ? ({ app_id: opts.appId } as unknown as ClientConfig) : undefined; + const manager = new AuthManager({ + baseURL: "https://api.test", fetch, initialClientConfig, + plugins: [subscription()], + storage: { + getItem: (k) => store.get(k) ?? null, + setItem: (k, v) => { store.set(k, v); }, + removeItem: (k) => { store.delete(k); }, + }, + }); + managers.push(manager); + return { manager, routes, calls, api: () => manager.plugin(subscription) }; +} + +describe("models", () => { + it("maps the server's view into typed, camelCase models", async () => { + const { manager, routes, api } = setup(); + routes.set("GET /v1/billing/subscription", () => json({ + can_manage: false, + subscription: subJSON({ + status: "trialing", trial_end: new Date(Date.now() + 3 * 86_400_000).toISOString(), + cancel_at: "2026-12-01T00:00:00Z", + seats: { feature_key: "seats", used: 3, limit: null }, + plan: planJSON("team", { features: [{ key: "api", name: "API", type: "metered", limit: null, soft_limit: true, period: "monthly" }] }), + }), + })); + await manager.initialize(); + await api().load(); + + const snap = api().getSnapshot(); + const sub = snap.subscription.data as Subscription; + expect(snap.subscription.status).toBe("ready"); + expect(snap.canManage).toBe(false); + expect(sub.plan.slug).toBe("team"); + expect(sub.plan.price).toEqual({ amountMinor: 2900, currency: "usd", interval: "monthly" }); + expect(sub.plan.features[0]).toEqual({ key: "api", name: "API", type: "metered", limit: null, period: "monthly", softLimit: true }); + expect(sub.seats).toEqual({ featureKey: "seats", used: 3, limit: null }); + expect(sub.isTrialing).toBe(true); + expect(sub.isActive).toBe(true); + expect(sub.willCancel).toBe(true); + expect(sub.cancelAt).toBe("2026-12-01T00:00:00Z"); + expect(sub.trialDaysLeft).toBe(3); + }); + + it("formats money in minor units by the currency's digits", () => { + expect(formatMoney({ amountMinor: 2900, currency: "usd" }, "en-US")).toBe("$29.00"); + expect(formatMoney({ amountMinor: 500, currency: "jpy" }, "en-US")).toBe("¥500"); + }); +}); + +describe("loading", () => { + it("loads the subscription, can_manage and entitlements together", async () => { + const { manager, api } = setup(); + await manager.initialize(); + await api().load(); + const snap = api().getSnapshot(); + expect(snap.subscription.data?.id).toBe("sub_1"); + expect(snap.canManage).toBe(true); + expect(snap.entitlements.status).toBe("ready"); + expect(api().entitlement("sso")?.allowed).toBe(true); + expect(api().entitlement("nope")).toBeNull(); + }); + + it("is unavailable, without asking, when the server has no subscription plugin", async () => { + const { manager, api, calls } = setup({ serverPlugins: ["organization"] }); + await manager.initialize(); + await api().load(); + expect(api().getSnapshot().subscription.status).toBe("unavailable"); + expect(api().getSnapshot().entitlements.status).toBe("unavailable"); + expect(calls.some((c) => c.includes("/v1/billing"))).toBe(false); + }); + + it("drops a response for the org the session has since left", async () => { + const { manager, routes, api } = setup(); + await manager.initialize(); + let release!: () => void; + const gate = new Promise((r) => (release = r)); + routes.set("GET /v1/billing/subscription", async () => { + await gate; + return json({ subscription: subJSON({ tenant_id: "org_1" }), can_manage: true }); + }); + const loading = api().load(); + + routes.set("POST /v1/me/switch-org", () => json({ session_id: "s", org_id: "org_2" })); + routes.set("GET /v1/billing/subscription", () => json({ subscription: null, can_manage: true })); + await manager.switchOrg("org_2"); + release(); + await loading; + await vi.waitFor(() => expect(api().getSnapshot().subscription.status).toBe("ready")); + expect(api().getSnapshot().subscription.data).toBeNull(); + }); + + it("resets on sign-out and reloads on an org switch", async () => { + const { manager, routes, api, calls } = setup(); + await manager.initialize(); + await api().load(); + routes.set("POST /v1/me/switch-org", () => json({ session_id: "s", org_id: "org_2" })); + const before = calls.filter((c) => c.startsWith("GET /v1/billing/subscription")).length; + await manager.switchOrg("org_2"); + await vi.waitFor(() => + expect(calls.filter((c) => c.startsWith("GET /v1/billing/subscription")).length).toBe(before + 1)); + + await manager.signOut(); + expect(api().getSnapshot().subscription.status).toBe("idle"); + expect(api().getSnapshot().subscription.data).toBeNull(); + }); + + it("reads plans for the client config's app, else the user's", async () => { + const withConfig = setup({ appId: "app_cfg" }); + withConfig.routes.set("GET /v1/billing/plans", () => json({ plans: [planJSON("free", { amount_minor: undefined, base_amount: undefined, billing_period: undefined }), planJSON("pro")], total: 2 })); + await withConfig.manager.initialize(); + await withConfig.api().loadPlans(); + expect(withConfig.calls).toContain("GET /v1/billing/plans?app_id=app_cfg"); + expect(withConfig.api().getSnapshot().plans.data.map((p) => p.slug)).toEqual(["free", "pro"]); + expect(withConfig.api().getSnapshot().plans.data[0].price).toBeNull(); + + const fromUser = setup(); + fromUser.routes.set("GET /v1/billing/plans", () => json({ plans: [], total: 0 })); + await fromUser.manager.initialize(); + await fromUser.api().loadPlans(); + expect(fromUser.calls).toContain("GET /v1/billing/plans?app_id=app_user"); + }); +}); + +describe("writes", () => { + it("writes a changed subscription into the cache", async () => { + const { manager, routes, api } = setup(); + await manager.initialize(); + await api().load(); + routes.set("PATCH /v1/billing/subscription", (init) => { + expect(JSON.parse(String(init?.body))).toEqual({ plan: "team", seats: 5 }); + return json({ subscription: subJSON({ plan: planJSON("team") }) }); + }); + + const result = await api().changePlan("team", { seats: 5 }); + expect(result.type).toBe("changed"); + expect(api().getSnapshot().subscription.data?.plan.slug).toBe("team"); + }); + + it("leaves the cache alone when payment comes first", async () => { + const { manager, routes, api } = setup(); + await manager.initialize(); + await api().load(); + routes.set("POST /v1/billing/subscription", () => json({ checkout_url: "https://pay.example/1" })); + + const result = await api().subscribe("business", { returnUrl: "https://app/billing" }); + expect(result).toEqual({ type: "checkout", url: "https://pay.example/1" }); + expect(api().getSnapshot().subscription.data?.plan.slug).toBe("pro"); + }); + + it("surfaces a refused write without touching the cache", async () => { + const { manager, routes, api } = setup(); + await manager.initialize(); + await api().load(); + routes.set("POST /v1/billing/subscription/cancel", () => json({ error: "your role can't manage billing for this organization" }, 403)); + + await expect(api().cancel()).rejects.toMatchObject({ code: 403 }); + expect(api().getSnapshot().subscription.data?.cancelAt).toBeNull(); + }); + + it("cancels at the period end by default and resumes", async () => { + const { manager, routes, api } = setup(); + await manager.initialize(); + await api().load(); + routes.set("POST /v1/billing/subscription/cancel", (init) => { + expect(JSON.parse(String(init?.body))).toEqual({ when: "period_end" }); + return json({ subscription: subJSON({ cancel_at: "2026-12-01T00:00:00Z" }) }); + }); + routes.set("POST /v1/billing/subscription/resume", () => json({ subscription: subJSON() })); + + expect((await api().cancel()).willCancel).toBe(true); + expect(api().getSnapshot().subscription.data?.willCancel).toBe(true); + expect((await api().resume()).willCancel).toBe(false); + }); + + it("notifies listeners on every change", async () => { + const { manager, api } = setup(); + await manager.initialize(); + const listener = vi.fn(); + api().onChange(listener); + await api().load(); + expect(listener).toHaveBeenCalled(); + }); +}); diff --git a/ui/packages/plugin-subscription/src/plugin.ts b/ui/packages/plugin-subscription/src/plugin.ts new file mode 100644 index 00000000..05c1f349 --- /dev/null +++ b/ui/packages/plugin-subscription/src/plugin.ts @@ -0,0 +1,269 @@ +import { definePlugin, type PluginContext } from "@authsome/ui-core"; + +import { + toEntitlement, + toPlan, + toSubscription, + type Entitlement, + type EntitlementJSON, + type Plan, + type PlanJSON, + type Subscription, + type SubscriptionJSON, +} from "./models"; + +/** + * One piece of server state. "unavailable" means the server doesn't have the + * subscription plugin installed; treat it like a plan without the feature. + */ +export interface Resource { + status: "idle" | "loading" | "ready" | "error" | "unavailable"; + data: T; + error: Error | null; +} + +/** Everything the plugin knows, as one immutable snapshot. */ +export interface SubscriptionSnapshot { + /** The tenant's subscription; data is null when it has none. */ + subscription: Resource; + /** Whether the signed-in user may change the subscription. */ + canManage: boolean; + /** Plans the tenant can buy, cheapest first. */ + plans: Resource; + /** Every feature on the current plan, checked. */ + entitlements: Resource>; +} + +/** What a plan or seat change did. */ +export type ChangeResult = + | { type: "changed"; subscription: Subscription } + /** Payment comes first: send the customer to url. Nothing changed yet. */ + | { type: "checkout"; url: string }; + +export interface ChangeOptions { + seats?: number; + /** Where the payment page sends the customer back to. */ + returnUrl?: string; + /** Where it sends them if they back out. */ + cancelUrl?: string; +} + +export interface SubscriptionApi { + getSnapshot(): SubscriptionSnapshot; + /** Listen for snapshot changes. Returns a function that stops listening. */ + onChange(listener: () => void): () => void; + /** Loads the subscription and its entitlements. */ + load(): Promise; + /** Loads the plans the tenant can buy. */ + loadPlans(): Promise; + /** Starts a subscription on a plan, by slug or ID. */ + subscribe(plan: string, options?: ChangeOptions): Promise; + /** Moves the subscription to another plan, by slug or ID. */ + changePlan(plan: string, options?: ChangeOptions): Promise; + /** Changes the seat count on the current plan. */ + setSeats(seats: number, options?: Omit): Promise; + /** Cancels at the end of the period (the default) or now. */ + cancel(options?: { when?: "period_end" | "now" }): Promise; + /** Takes back a cancellation scheduled for the end of the period. */ + resume(): Promise; + /** A link to the payment provider's billing portal. */ + openPortal(returnUrl?: string): Promise; + /** The cached check for a feature, null when it isn't on the plan or not loaded. */ + entitlement(feature: string): Entitlement | null; +} + +const idle = (data: T): Resource => ({ status: "idle", data, error: null }); + +function initialSnapshot(): SubscriptionSnapshot { + return { + subscription: idle(null), + canManage: false, + plans: idle([]), + entitlements: idle>({}), + }; +} + +function asError(err: unknown): Error { + return err instanceof Error ? err : new Error(String(err)); +} + +/** Builds the subscription API over a plugin context. */ +export function createSubscriptionApi(ctx: PluginContext): SubscriptionApi { + let snapshot = initialSnapshot(); + const listeners = new Set<() => void>(); + // Bumped whenever the session or organization changes, so a response for + // the one the user just left is dropped instead of shown. + let generation = 0; + + const set = (patch: Partial) => { + snapshot = { ...snapshot, ...patch }; + for (const listener of listeners) { + try { + listener(); + } catch { + // A listener's error mustn't stop the others. + } + } + }; + + const unavailable = () => ctx.isServerEnabled() === false; + const signedIn = () => ctx.getState().status === "authenticated"; + + const loadEntitlements = async (gen: number) => { + set({ entitlements: { ...snapshot.entitlements, status: "loading", error: null } }); + try { + const res = await ctx.request<{ entitlements: Record }>( + "GET", + "/v1/billing/entitlements", + ); + if (gen !== generation) return; + const entitlements: Record = {}; + for (const [key, value] of Object.entries(res.entitlements ?? {})) { + entitlements[key] = toEntitlement(value); + } + set({ entitlements: { status: "ready", data: entitlements, error: null } }); + } catch (err) { + if (gen !== generation) return; + set({ entitlements: { ...snapshot.entitlements, status: "error", error: asError(err) } }); + } + }; + + const load = async () => { + if (unavailable()) { + set({ + subscription: { status: "unavailable", data: null, error: null }, + entitlements: { status: "unavailable", data: {}, error: null }, + }); + return; + } + if (!signedIn()) return; + const gen = generation; + set({ subscription: { ...snapshot.subscription, status: "loading", error: null } }); + const entitlements = loadEntitlements(gen); + try { + const res = await ctx.request<{ subscription: SubscriptionJSON | null; can_manage: boolean }>( + "GET", + "/v1/billing/subscription", + ); + if (gen !== generation) return; + set({ + subscription: { + status: "ready", + data: res.subscription ? toSubscription(res.subscription) : null, + error: null, + }, + canManage: res.can_manage, + }); + } catch (err) { + if (gen !== generation) return; + set({ subscription: { ...snapshot.subscription, status: "error", error: asError(err) } }); + } + await entitlements; + }; + + const loadPlans = async () => { + if (unavailable()) { + set({ plans: { status: "unavailable", data: [], error: null } }); + return; + } + // /plans is app-wide and wants the app's ID: the client config carries + // it, and so does the signed-in user. + const state = ctx.getState(); + const appId = + ctx.getClientConfig()?.app_id ?? + (state.status === "authenticated" ? state.user.app_id : undefined); + set({ plans: { ...snapshot.plans, status: "loading", error: null } }); + try { + const res = await ctx.request<{ plans: PlanJSON[] }>("GET", "/v1/billing/plans", { + query: { app_id: appId }, + }); + set({ plans: { status: "ready", data: (res.plans ?? []).map(toPlan), error: null } }); + } catch (err) { + set({ plans: { ...snapshot.plans, status: "error", error: asError(err) } }); + } + }; + + // Writes answer with the new subscription. It goes straight into the + // cache, and the entitlements are rechecked since the plan may have moved. + const commit = (json: SubscriptionJSON | undefined): Subscription | null => { + if (!json) return null; + const sub = toSubscription(json); + set({ subscription: { status: "ready", data: sub, error: null } }); + void loadEntitlements(generation); + return sub; + }; + + const change = async ( + method: "POST" | "PATCH", + body: Record, + ): Promise => { + const res = await ctx.request<{ subscription?: SubscriptionJSON; checkout_url?: string }>( + method, + "/v1/billing/subscription", + { body }, + ); + if (res.checkout_url) return { type: "checkout", url: res.checkout_url }; + const sub = commit(res.subscription); + if (!sub) throw new Error("authsome: the server answered with neither a subscription nor a checkout URL"); + return { type: "changed", subscription: sub }; + }; + + const changeBody = (options: ChangeOptions | undefined, extra: Record) => ({ + ...extra, + ...(options?.seats !== undefined ? { seats: options.seats } : {}), + ...(options?.returnUrl ? { return_url: options.returnUrl } : {}), + ...(options?.cancelUrl ? { cancel_url: options.cancelUrl } : {}), + }); + + const mutate = async (path: string, body: unknown): Promise => { + const res = await ctx.request<{ subscription?: SubscriptionJSON }>("POST", path, { body }); + const sub = commit(res.subscription); + if (!sub) throw new Error("authsome: the server answered without a subscription"); + return sub; + }; + + // Follow the session: a sign-out forgets everything, and a sign-in or an + // organization switch reloads whatever had been loaded. + ctx.on("signedOut", () => { + generation++; + snapshot = initialSnapshot(); + set({}); + }); + const reload = () => { + generation++; + if (snapshot.subscription.status !== "idle") void load(); + }; + ctx.on("signedIn", reload); + ctx.on("orgChanged", reload); + + return { + getSnapshot: () => snapshot, + onChange(listener) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + load, + loadPlans, + subscribe: (plan, options) => change("POST", changeBody(options, { plan })), + changePlan: (plan, options) => change("PATCH", changeBody(options, { plan })), + setSeats: (seats, options) => change("PATCH", changeBody({ ...options, seats }, {})), + cancel: (options) => mutate("/v1/billing/subscription/cancel", { when: options?.when ?? "period_end" }), + resume: () => mutate("/v1/billing/subscription/resume", {}), + async openPortal(returnUrl) { + const res = await ctx.request<{ url: string }>("POST", "/v1/billing/portal", { + body: returnUrl ? { return_url: returnUrl } : {}, + }); + return res.url; + }, + entitlement: (feature) => snapshot.entitlements.data[feature] ?? null, + }; +} + +/** + * The subscription client plugin. Register it with `plugins: [subscription()]` + * and read it with `manager.plugin(subscription)` or, in React, + * `useSubscription()`. + */ +export const subscription = definePlugin("subscription", (ctx) => createSubscriptionApi(ctx)); diff --git a/ui/packages/plugin-subscription/tsconfig.json b/ui/packages/plugin-subscription/tsconfig.json new file mode 100644 index 00000000..eb3621e3 --- /dev/null +++ b/ui/packages/plugin-subscription/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "jsx": "react-jsx", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/ui/packages/plugin-subscription/tsup.config.ts b/ui/packages/plugin-subscription/tsup.config.ts new file mode 100644 index 00000000..60ce7035 --- /dev/null +++ b/ui/packages/plugin-subscription/tsup.config.ts @@ -0,0 +1,23 @@ +import { defineConfig } from "tsup"; + +// Three entries so an app pays only for what it imports: the framework-free +// plugin, the React hooks, and the styled components built on +// @authsome/ui-components. +export default defineConfig({ + entry: ["src/index.ts", "src/react.tsx", "src/components.tsx"], + format: ["esm"], + dts: true, + clean: true, + sourcemap: true, + target: "es2022", + external: [ + "react", + "react-dom", + "@authsome/ui-core", + "@authsome/ui-react", + "@authsome/ui-components", + ], + banner: { + js: '"use client";', + }, +}); diff --git a/ui/packages/plugin-subscription/vitest.config.ts b/ui/packages/plugin-subscription/vitest.config.ts new file mode 100644 index 00000000..2fcc35bd --- /dev/null +++ b/ui/packages/plugin-subscription/vitest.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from "vitest/config"; + +// ui-core and ui-nextjs get by on vitest's defaults because they test plain +// modules. This package renders components, so it needs a DOM. +// +// globals is on so @testing-library/react can register its own afterEach +// cleanup. Without it every render stays in the document and the next test +// finds several copies of the same element. Tests still import describe/it/ +// expect explicitly, the same way the other packages do. +export default defineConfig({ + test: { + environment: "jsdom", + globals: true, + include: ["src/**/*.test.{ts,tsx}"], + }, +}); diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index 541316a9..e0140950 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -257,6 +257,49 @@ importers: specifier: ^4.1.12 version: 4.1.12(@types/node@26.3.0)(jsdom@28.1.0)(vite@8.3.5(@types/node@26.3.0)(esbuild@0.28.2)(jiti@2.7.0)) + packages/plugin-subscription: + dependencies: + '@authsome/ui-core': + specifier: workspace:* + version: link:../core + devDependencies: + '@authsome/ui-components': + specifier: workspace:* + version: link:../components + '@authsome/ui-react': + specifier: workspace:* + version: link:../react + '@testing-library/react': + specifier: ^16.3.0 + version: 16.3.2(@testing-library/dom@10.4.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@types/react': + specifier: ^19.2.18 + version: 19.2.18 + '@types/react-dom': + specifier: ^19.2.5 + version: 19.2.5(@types/react@19.2.18) + jsdom: + specifier: ^28.0.1 + version: 28.1.0 + react: + specifier: ^19.2.8 + version: 19.2.8 + react-dom: + specifier: ^19.2.8 + version: 19.2.8(react@19.2.8) + tsup: + specifier: ^8.5.1 + version: 8.5.1(jiti@2.7.0)(postcss@8.5.26)(typescript@5.9.3) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vite: + specifier: ^8.2.4 + version: 8.3.5(@types/node@26.3.0)(esbuild@0.28.2)(jiti@2.7.0) + vitest: + specifier: ^4.1.12 + version: 4.1.12(@types/node@26.3.0)(jsdom@28.1.0)(vite@8.3.5(@types/node@26.3.0)(esbuild@0.28.2)(jiti@2.7.0)) + packages/react: dependencies: '@authsome/ui-core': From e97b9d38d28b17a8c60551a4055a8722b0816ccb Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Tue, 10 Nov 2026 10:07:41 -0600 Subject: [PATCH 02/12] 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. shows its children only on a positive answer: loading, an error or billing not being installed all show the fallback. --- .../plugin-subscription/src/react.test.tsx | 123 ++++++++++++ ui/packages/plugin-subscription/src/react.tsx | 177 ++++++++++++++++++ .../plugin-subscription/src/test-support.tsx | 85 +++++++++ 3 files changed, 385 insertions(+) create mode 100644 ui/packages/plugin-subscription/src/react.test.tsx create mode 100644 ui/packages/plugin-subscription/src/react.tsx create mode 100644 ui/packages/plugin-subscription/src/test-support.tsx diff --git a/ui/packages/plugin-subscription/src/react.test.tsx b/ui/packages/plugin-subscription/src/react.test.tsx new file mode 100644 index 00000000..3d8e669d --- /dev/null +++ b/ui/packages/plugin-subscription/src/react.test.tsx @@ -0,0 +1,123 @@ +import { act, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { Entitled, usePlans, useSubscription } from "./react"; +import { harness, json, planJSON, subJSON } from "./test-support"; + +describe("useSubscription", () => { + it("loads on mount and re-renders after a plan change", async () => { + const h = harness(); + h.routes.set("PATCH /v1/billing/subscription", () => json({ subscription: subJSON({ plan: planJSON("team") }) })); + let seen = null as unknown as ReturnType; + function Probe() { + seen = useSubscription(); + return {seen.isLoading ? "loading" : seen.plan?.name ?? "none"}; + } + await h.mount(); + await waitFor(() => expect(screen.getByTestId("plan").textContent).toBe("Pro")); + expect(seen.canManage).toBe(true); + + await act(async () => { + const result = await seen.changePlan("team"); + expect(result.type).toBe("changed"); + }); + expect(screen.getByTestId("plan").textContent).toBe("Team"); + expect(seen.isPending).toBe(false); + }); + + it("reports pending while a write is in flight", async () => { + const h = harness(); + let release!: () => void; + const gate = new Promise((r) => (release = r)); + h.routes.set("POST /v1/billing/subscription/cancel", async () => { + await gate; + return json({ subscription: subJSON({ cancel_at: "2026-12-01T00:00:00Z" }) }); + }); + let seen = null as unknown as ReturnType; + function Probe() { + seen = useSubscription(); + return null; + } + await h.mount(); + await waitFor(() => expect(seen.subscription).not.toBeNull()); + + let canceling!: Promise; + act(() => { + canceling = seen.cancel(); + }); + await waitFor(() => expect(seen.isPending).toBe(true)); + await act(async () => { + release(); + await canceling; + }); + expect(seen.isPending).toBe(false); + expect(seen.subscription?.willCancel).toBe(true); + }); +}); + +describe("usePlans", () => { + it("loads the plans on mount", async () => { + let seen = null as unknown as ReturnType; + function Probe() { + seen = usePlans(); + return null; + } + await harness().mount(); + await waitFor(() => expect(seen.plans.map((p) => p.slug)).toEqual(["free", "pro", "team"])); + expect(seen.isLoading).toBe(false); + }); +}); + +describe("Entitled", () => { + it("shows the loading slot, then the children when the plan allows it", async () => { + const h = harness(); + let release!: () => void; + const gate = new Promise((r) => (release = r)); + const original = h.routes.get("GET /v1/billing/entitlements")!; + h.routes.set("GET /v1/billing/entitlements", async () => { + await gate; + return original(); + }); + await h.mount( + upgrade} loading={checking}> + sso-settings + , + ); + expect(screen.queryByText("checking")).not.toBeNull(); + expect(screen.queryByText("sso-settings")).toBeNull(); + await act(async () => release()); + await waitFor(() => expect(screen.queryByText("sso-settings")).not.toBeNull()); + }); + + it("falls back for a feature the plan doesn't have", async () => { + await harness().mount( + upgrade}> + audit + , + ); + await waitFor(() => expect(screen.queryByText("upgrade")).not.toBeNull()); + expect(screen.queryByText("audit")).toBeNull(); + }); + + it("falls back when the server has no subscription plugin", async () => { + const h = harness({ serverPlugins: ["organization"] }); + await h.mount( + upgrade}> + sso-settings + , + ); + await waitFor(() => expect(screen.queryByText("upgrade")).not.toBeNull()); + expect(h.calls.some((c) => c.includes("/v1/billing"))).toBe(false); + }); + + it("falls back on an error", async () => { + const h = harness(); + h.routes.set("GET /v1/billing/entitlements", () => json({ error: "down" }, 500)); + await h.mount( + upgrade}> + sso-settings + , + ); + await waitFor(() => expect(screen.queryByText("upgrade")).not.toBeNull()); + }); +}); diff --git a/ui/packages/plugin-subscription/src/react.tsx b/ui/packages/plugin-subscription/src/react.tsx new file mode 100644 index 00000000..11324a3f --- /dev/null +++ b/ui/packages/plugin-subscription/src/react.tsx @@ -0,0 +1,177 @@ +/** + * React bindings for the subscription client plugin. + * + * ```tsx + * ... + * + * const { plan, subscription, changePlan } = useSubscription(); + * }> + * ``` + */ + +import { useAuth, usePlugin } from "@authsome/ui-react"; +import { + useCallback, + useEffect, + useState, + useSyncExternalStore, + type ReactNode, +} from "react"; + +import type { Entitlement, Plan, Subscription } from "./models"; +import { + subscription, + type ChangeOptions, + type ChangeResult, + type Resource, + type SubscriptionApi, + type SubscriptionSnapshot, +} from "./plugin"; + +/** The plugin's API, for anything the hooks don't cover. */ +export function useSubscriptionApi(): SubscriptionApi { + return usePlugin(subscription); +} + +function useSnapshot(api: SubscriptionApi): SubscriptionSnapshot { + return useSyncExternalStore(api.onChange, api.getSnapshot, api.getSnapshot); +} + +/** Loads a resource once the user is signed in and it hasn't been loaded. */ +function useAutoLoad(status: Resource["status"], load: () => Promise) { + const { isAuthenticated } = useAuth(); + useEffect(() => { + if (isAuthenticated && status === "idle") void load(); + }, [isAuthenticated, status, load]); + return isAuthenticated; +} + +const isPendingStatus = (status: Resource["status"], signedIn: boolean) => + status === "loading" || (status === "idle" && signedIn); + +/** Counts writes in flight, so components can disable buttons. */ +function usePendingWrites() { + const [pending, setPending] = useState(0); + const track = useCallback((work: () => Promise): Promise => { + setPending((n) => n + 1); + return work().finally(() => setPending((n) => n - 1)); + }, []); + return { isPending: pending > 0, track }; +} + +export interface UseSubscriptionResult { + /** The tenant's subscription, null when it has none (or before it loads). */ + subscription: Subscription | null; + /** The subscription's plan. */ + plan: Plan | null; + /** Whether you may change it. Hide upgrade buttons when false. */ + canManage: boolean; + status: Resource["status"]; + isLoading: boolean; + error: Error | null; + refresh: () => Promise; + subscribe: (plan: string, options?: ChangeOptions) => Promise; + changePlan: (plan: string, options?: ChangeOptions) => Promise; + setSeats: (seats: number, options?: Omit) => Promise; + cancel: (options?: { when?: "period_end" | "now" }) => Promise; + resume: () => Promise; + openPortal: (returnUrl?: string) => Promise; + /** A write is in flight. */ + isPending: boolean; +} + +/** + * The tenant's subscription and everything you can do to it. Loads on first + * use; writes update every component reading it. + */ +export function useSubscription(): UseSubscriptionResult { + const api = useSubscriptionApi(); + const snap = useSnapshot(api); + const signedIn = useAutoLoad(snap.subscription.status, api.load); + const { isPending, track } = usePendingWrites(); + + return { + subscription: snap.subscription.data, + plan: snap.subscription.data?.plan ?? null, + canManage: snap.canManage, + status: snap.subscription.status, + isLoading: isPendingStatus(snap.subscription.status, signedIn), + error: snap.subscription.error, + refresh: api.load, + subscribe: (plan, options) => track(() => api.subscribe(plan, options)), + changePlan: (plan, options) => track(() => api.changePlan(plan, options)), + setSeats: (seats, options) => track(() => api.setSeats(seats, options)), + cancel: (options) => track(() => api.cancel(options)), + resume: () => track(() => api.resume()), + openPortal: (returnUrl) => track(() => api.openPortal(returnUrl)), + isPending, + }; +} + +/** The plans the tenant can buy, cheapest first. Loads on first use. */ +export function usePlans(): { + plans: Plan[]; + status: Resource["status"]; + isLoading: boolean; + error: Error | null; + refresh: () => Promise; +} { + const api = useSubscriptionApi(); + const snap = useSnapshot(api); + const signedIn = useAutoLoad(snap.plans.status, api.loadPlans); + return { + plans: snap.plans.data, + status: snap.plans.status, + isLoading: isPendingStatus(snap.plans.status, signedIn), + error: snap.plans.error, + refresh: api.loadPlans, + }; +} + +/** + * Whether the plan includes a feature. `allowed` stays false until the check + * has loaded, and on an error, so gating on it fails closed. + */ +export function useEntitlement(feature: string): { + allowed: boolean; + entitlement: Entitlement | null; + status: Resource["status"]; + isLoading: boolean; +} { + const api = useSubscriptionApi(); + const snap = useSnapshot(api); + // Entitlements load with the subscription. + const signedIn = useAutoLoad(snap.subscription.status, api.load); + const entitlement = snap.entitlements.data[feature] ?? null; + return { + allowed: snap.entitlements.status === "ready" && entitlement?.allowed === true, + entitlement, + status: snap.entitlements.status, + isLoading: isPendingStatus(snap.entitlements.status, signedIn), + }; +} + +export interface EntitledProps { + /** The plan feature to check, e.g. "sso". */ + feature: string; + children: ReactNode; + /** Shown when the plan doesn't include the feature, on an error, or when billing isn't installed. */ + fallback?: ReactNode; + /** Shown while the check loads. Defaults to the fallback. */ + loading?: ReactNode; +} + +/** + * Shows its children only when the plan includes the feature. Anything short + * of a positive answer (loading, an error, billing not installed) shows the + * fallback, so a paywall never opens by accident. + */ +export function Entitled({ feature, children, fallback = null, loading }: EntitledProps) { + const { allowed, isLoading } = useEntitlement(feature); + if (allowed) return <>{children}; + if (isLoading) return <>{loading ?? fallback}; + return <>{fallback}; +} + +export { subscription }; +export type { ChangeOptions, ChangeResult, Resource, SubscriptionApi, SubscriptionSnapshot }; diff --git a/ui/packages/plugin-subscription/src/test-support.tsx b/ui/packages/plugin-subscription/src/test-support.tsx new file mode 100644 index 00000000..065b66f5 --- /dev/null +++ b/ui/packages/plugin-subscription/src/test-support.tsx @@ -0,0 +1,85 @@ +import type { ClientConfig } from "@authsome/ui-core"; +import { AuthProvider } from "@authsome/ui-react"; +import { act, render } from "@testing-library/react"; +import type { ReactNode } from "react"; + +import { subscription } from "./plugin"; + +export const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }); + +export function planJSON(slug: string, overrides: Record = {}) { + return { + id: `plan_${slug}`, name: slug[0].toUpperCase() + slug.slice(1), slug, description: "", + currency: "usd", status: "active", trial_days: 0, features_count: 1, is_addon: false, app_id: "app_1", + base_amount: "29.00", billing_period: "monthly", amount_minor: 2900, + features: [{ key: "sso", name: "SSO", type: "boolean", limit: 1, soft_limit: false }], + ...overrides, + }; +} + +export function subJSON(overrides: Record = {}) { + return { + id: "sub_1", tenant_id: "org_1", status: "active", plan: planJSON("pro"), + current_period_start: "2026-11-01T00:00:00Z", current_period_end: "2026-12-01T00:00:00Z", + trial_start: null, trial_end: null, cancel_at: null, canceled_at: null, paused_at: null, + app_id: "app_1", + ...overrides, + }; +} + +export type Handler = (init?: RequestInit) => Response | Promise; + +/** + * A signed-in AuthProvider with the subscription plugin, over a routed + * fetch. Routes default to an active "pro" subscription the user can manage. + */ +export function harness(opts: { serverPlugins?: string[] } = {}) { + const store = new Map([[ + "authsome:session", + JSON.stringify({ session_token: "tok", refresh_token: "ref", expires_at: new Date(Date.now() + 3_600_000).toISOString() }), + ]]); + const routes = new Map([ + ["GET /v1/me", () => json({ id: "usr_1", email: "a@test", app_id: "app_1", active_org_id: "org_1" })], + ["GET /v1/billing/subscription", () => json({ subscription: subJSON(), can_manage: true })], + ["GET /v1/billing/entitlements", () => json({ entitlements: { sso: { allowed: true, feature: "sso", used: 2, limit: 10, remaining: 8 } } })], + ["GET /v1/billing/plans", () => json({ plans: [planJSON("free", { amount_minor: undefined, base_amount: undefined }), planJSON("pro"), planJSON("team", { amount_minor: 9900, base_amount: "99.00" })] })], + ]); + const calls: string[] = []; + const fetchFn = (async (input: string | URL, init?: RequestInit) => { + const url = new URL(String(input)); + const key = `${init?.method ?? "GET"} ${url.pathname}`; + calls.push(key); + const handler = routes.get(key); + return handler ? handler(init) : json({ error: `no route ${key}` }, 404); + }) as unknown as typeof fetch; + const initialClientConfig = opts.serverPlugins + ? ({ supported_plugins: opts.serverPlugins } as unknown as ClientConfig) + : undefined; + + const wrap = (children: ReactNode) => ( + store.get(k) ?? null, + setItem: (k, v) => void store.set(k, v), + removeItem: (k) => void store.delete(k), + }} + > + {children} + + ); + + return { + routes, + calls, + async mount(node: ReactNode): Promise { + await act(async () => { + render(wrap(node)); + }); + }, + }; +} From d3e9fd870cc0c77ca39788e08d7f65cfcd6fdf29 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Tue, 10 Nov 2026 10:10:06 -0600 Subject: [PATCH 03/12] 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. --- .../src/components.test.tsx | 127 +++++++ .../plugin-subscription/src/components.tsx | 343 ++++++++++++++++++ 2 files changed, 470 insertions(+) create mode 100644 ui/packages/plugin-subscription/src/components.test.tsx create mode 100644 ui/packages/plugin-subscription/src/components.tsx diff --git a/ui/packages/plugin-subscription/src/components.test.tsx b/ui/packages/plugin-subscription/src/components.test.tsx new file mode 100644 index 00000000..25c1bee8 --- /dev/null +++ b/ui/packages/plugin-subscription/src/components.test.tsx @@ -0,0 +1,127 @@ +import { act, fireEvent, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { CurrentPlanCard, ManageBillingButton, PricingTable, UsageMeter } from "./components"; +import { harness, json, planJSON, subJSON } from "./test-support"; + +describe("PricingTable", () => { + it("lists the plans with prices and marks the current one", async () => { + await harness().mount(); + await waitFor(() => expect(screen.getByText("Team")).toBeTruthy()); + expect(screen.getByText("$99.00")).toBeTruthy(); + expect(screen.getByText("Free")).toBeTruthy(); + expect((screen.getByRole("button", { name: "Current plan" }) as HTMLButtonElement).disabled).toBe(true); + }); + + it("changes plan when there's a subscription", async () => { + const h = harness(); + const patch = vi.fn(() => json({ subscription: subJSON({ plan: planJSON("team", { amount_minor: 9900 }) }) })); + h.routes.set("PATCH /v1/billing/subscription", patch); + await h.mount(); + const choose = await screen.findByRole("button", { name: "Choose Team" }); + await act(async () => { fireEvent.click(choose); }); + expect(patch).toHaveBeenCalledTimes(1); + await waitFor(() => expect(screen.getAllByRole("button", { name: "Current plan" })).toHaveLength(1)); + }); + + it("subscribes when there isn't one, and hands a checkout URL to onCheckout", async () => { + const h = harness(); + h.routes.set("GET /v1/billing/subscription", () => json({ subscription: null, can_manage: true })); + h.routes.set("POST /v1/billing/subscription", () => json({ checkout_url: "https://pay.example/s/1" })); + const onCheckout = vi.fn(); + await h.mount(); + const choose = await screen.findByRole("button", { name: "Choose Pro" }); + await act(async () => { fireEvent.click(choose); }); + expect(onCheckout).toHaveBeenCalledWith("https://pay.example/s/1"); + }); + + it("lets the app take over selection", async () => { + const onSelect = vi.fn(); + await harness().mount(); + const choose = await screen.findByRole("button", { name: "Choose Team" }); + fireEvent.click(choose); + expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ slug: "team" })); + }); + + it("hides the buttons from someone who can't change the plan", async () => { + const h = harness(); + h.routes.set("GET /v1/billing/subscription", () => json({ subscription: subJSON(), can_manage: false })); + await h.mount(); + await waitFor(() => expect(screen.getByText("Team")).toBeTruthy()); + expect(screen.queryByRole("button", { name: "Choose Team" })).toBeNull(); + }); +}); + +describe("CurrentPlanCard", () => { + it("shows the plan, price, renewal date and seats", async () => { + const h = harness(); + h.routes.set("GET /v1/billing/subscription", () => json({ + can_manage: true, + subscription: subJSON({ seats: { feature_key: "seats", used: 3, limit: 10 } }), + })); + await h.mount(); + await waitFor(() => expect(screen.getByText("Pro")).toBeTruthy()); + expect(screen.getByText("$29.00 / monthly")).toBeTruthy(); + expect(screen.getByText(/Renews on Dec 1, 2026/)).toBeTruthy(); + expect(screen.getByText("3 of 10 seats")).toBeTruthy(); + expect(screen.getByRole("button", { name: "Cancel plan" })).toBeTruthy(); + }); + + it("says when a canceled plan ends and offers to keep it", async () => { + const h = harness(); + h.routes.set("GET /v1/billing/subscription", () => json({ + can_manage: true, subscription: subJSON({ cancel_at: "2026-12-01T00:00:00Z" }), + })); + const resume = vi.fn(() => json({ subscription: subJSON() })); + h.routes.set("POST /v1/billing/subscription/resume", resume); + await h.mount(); + await waitFor(() => expect(screen.getByText(/Ends on Dec 1, 2026/)).toBeTruthy()); + await act(async () => { fireEvent.click(screen.getByRole("button", { name: "Keep plan" })); }); + expect(resume).toHaveBeenCalledTimes(1); + await waitFor(() => expect(screen.getByText(/Renews on/)).toBeTruthy()); + }); + + it("shows no buttons to someone who can't manage billing", async () => { + const h = harness(); + h.routes.set("GET /v1/billing/subscription", () => json({ subscription: subJSON(), can_manage: false })); + await h.mount(); + await waitFor(() => expect(screen.getByText("Pro")).toBeTruthy()); + expect(screen.queryByRole("button")).toBeNull(); + }); + + it("has an empty state", async () => { + const h = harness(); + h.routes.set("GET /v1/billing/subscription", () => json({ subscription: null, can_manage: true })); + await h.mount(); + await waitFor(() => expect(screen.getByText("No plan yet")).toBeTruthy()); + }); +}); + +describe("UsageMeter", () => { + it("shows use against the limit", async () => { + await harness().mount(); + await waitFor(() => expect(screen.getByText("2 of 10")).toBeTruthy()); + expect(screen.getByText("SSO connections")).toBeTruthy(); + expect(screen.getByRole("progressbar").getAttribute("aria-valuenow")).toBe("2"); + }); +}); + +describe("ManageBillingButton", () => { + it("opens the portal", async () => { + const h = harness(); + h.routes.set("POST /v1/billing/portal", () => json({ url: "https://pay.example/portal" })); + const onOpen = vi.fn(); + await h.mount(); + const button = await screen.findByRole("button", { name: "Manage billing" }); + await act(async () => { fireEvent.click(button); }); + expect(onOpen).toHaveBeenCalledWith("https://pay.example/portal"); + }); + + it("isn't shown to someone who can't manage billing", async () => { + const h = harness(); + h.routes.set("GET /v1/billing/subscription", () => json({ subscription: subJSON(), can_manage: false })); + await h.mount(); + await waitFor(() => expect(h.calls).toContain("GET /v1/billing/subscription")); + expect(screen.queryByRole("button")).toBeNull(); + }); +}); diff --git a/ui/packages/plugin-subscription/src/components.tsx b/ui/packages/plugin-subscription/src/components.tsx new file mode 100644 index 00000000..325bc3e6 --- /dev/null +++ b/ui/packages/plugin-subscription/src/components.tsx @@ -0,0 +1,343 @@ +/** + * Drop-in billing components, styled like @authsome/ui-components (whose + * stylesheet you already load for the auth forms). + * + * ```tsx + * + * + * + * + * ``` + */ + +import { + Badge, + Button, + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, + Skeleton, + cn, +} from "@authsome/ui-components"; +import { useState, type ReactNode } from "react"; + +import { formatMoney, type Plan, type Subscription } from "./models"; +import type { ChangeResult } from "./plugin"; +import { useEntitlement, usePlans, useSubscription } from "./react"; + +const redirect = (url: string) => window.location.assign(url); + +function formatDate(iso: string, locale?: string, timeZone?: string): string { + return new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeZone }).format(new Date(iso)); +} + +function ErrorLine({ children }: { children: ReactNode }) { + return ( +

+ {children} +

+ ); +} + +// ── PricingTable ────────────────────────────────── + +export interface PricingTableProps { + /** Take over selection yourself. Without it the table subscribes or changes plan. */ + onSelect?: (plan: Plan) => void; + /** Called with the payment page URL when a plan needs paying for. Defaults to redirecting. */ + onCheckout?: (url: string) => void; + /** Plan slug to draw attention to. */ + highlight?: string; + /** Where the payment page sends the customer back to. */ + returnUrl?: string; + /** Where it sends them if they back out. */ + cancelUrl?: string; + /** Locale for prices. Defaults to the browser's. */ + locale?: string; + className?: string; +} + +/** The plans you can buy, cheapest first, with a button on each. */ +export function PricingTable({ + onSelect, + onCheckout = redirect, + highlight, + returnUrl, + cancelUrl, + locale, + className, +}: PricingTableProps) { + const { plans, isLoading, error } = usePlans(); + const { subscription, canManage, subscribe, changePlan, isPending } = useSubscription(); + const [failed, setFailed] = useState(null); + + const choose = async (plan: Plan) => { + if (onSelect) { + onSelect(plan); + return; + } + setFailed(null); + try { + const options = { returnUrl, cancelUrl }; + const result: ChangeResult = subscription + ? await changePlan(plan.slug, options) + : await subscribe(plan.slug, options); + if (result.type === "checkout") onCheckout(result.url); + } catch (err) { + setFailed(err instanceof Error ? err.message : "Couldn't change plan"); + } + }; + + if (isLoading) { + return ( +
+ {[0, 1, 2].map((i) => ( + + ))} +
+ ); + } + if (error) return Couldn't load plans.; + + const showButtons = canManage || Boolean(onSelect); + return ( +
+
+ {plans.map((plan) => { + const current = subscription?.plan.id === plan.id; + return ( + + + {plan.name} + {plan.description && {plan.description}} + + +

+ + {plan.price ? formatMoney(plan.price, locale) : "No charge"} + + {plan.price?.interval && ( + / {plan.price.interval} + )} +

+ {plan.trialDays > 0 && !subscription && ( + {plan.trialDays}-day trial + )} + {plan.features.length > 0 && ( +
    + {plan.features.map((f) => ( +
  • + {f.name} + {f.type !== "boolean" && ( + + {" "} + {f.limit === null ? "unlimited" : `up to ${f.limit}`} + + )} +
  • + ))} +
+ )} +
+ {showButtons && ( + + + + )} +
+ ); + })} +
+ {failed && {failed}} +
+ ); +} + +// ── CurrentPlanCard ─────────────────────────────── + +export interface CurrentPlanCardProps { + /** Locale for prices and dates. Defaults to the browser's. */ + locale?: string; + /** Time zone for dates. Defaults to the browser's. */ + timeZone?: string; + className?: string; +} + +function statusLine(sub: Subscription, locale?: string, timeZone?: string): string { + const date = (iso: string) => formatDate(iso, locale, timeZone); + if (sub.isPaused) return "Paused"; + if (sub.willCancel && sub.cancelAt) return `Ends on ${date(sub.cancelAt)}`; + if (sub.isTrialing && sub.trialEnd) return `Trial ends on ${date(sub.trialEnd)}`; + if (sub.status === "past_due") return "Payment is overdue"; + return `Renews on ${date(sub.currentPeriodEnd)}`; +} + +/** The tenant's plan: name, price, when it renews or ends, seats, and cancel/keep. */ +export function CurrentPlanCard({ locale, timeZone, className }: CurrentPlanCardProps) { + const { subscription: sub, canManage, isLoading, error, cancel, resume, isPending } = + useSubscription(); + const [failed, setFailed] = useState(null); + + const run = async (write: () => Promise) => { + setFailed(null); + try { + await write(); + } catch (err) { + setFailed(err instanceof Error ? err.message : "Something went wrong"); + } + }; + + if (isLoading) return ; + if (error) return Couldn't load your plan.; + + if (!sub) { + return ( + + + No plan yet + Pick a plan to get started. + + + ); + } + + return ( + + +
+ {sub.plan.name} + {sub.isTrialing && Trial} + {sub.status === "past_due" && Past due} +
+ {sub.plan.price && ( + + {`${formatMoney(sub.plan.price, locale)} / ${sub.plan.price.interval}`} + + )} +
+ +

{statusLine(sub, locale, timeZone)}

+ {sub.seats && ( +

+ {sub.seats.limit === null + ? `${sub.seats.used} seats` + : `${sub.seats.used} of ${sub.seats.limit} seats`} +

+ )} + {failed && {failed}} +
+ {canManage && !sub.isPaused && ( + + {sub.willCancel ? ( + + ) : ( + + )} + + )} +
+ ); +} + +// ── UsageMeter ──────────────────────────────────── + +export interface UsageMeterProps { + /** The metered plan feature, e.g. "api_calls". */ + feature: string; + /** What to call it. Defaults to the feature key. */ + label?: string; + className?: string; +} + +/** How much of a metered feature is used, against the plan's limit. */ +export function UsageMeter({ feature, label, className }: UsageMeterProps) { + const { entitlement, isLoading } = useEntitlement(feature); + if (isLoading) return ; + if (!entitlement) return null; + + const unlimited = entitlement.limit < 0; + const percent = unlimited || entitlement.limit === 0 + ? 0 + : Math.min(100, Math.round((entitlement.used / entitlement.limit) * 100)); + return ( +
+
+ {label ?? feature} + + {unlimited ? `${entitlement.used} used` : `${entitlement.used} of ${entitlement.limit}`} + +
+
+
= 90 && "bg-destructive")} + style={{ width: `${percent}%` }} + /> +
+
+ ); +} + +// ── ManageBillingButton ─────────────────────────── + +export interface ManageBillingButtonProps { + /** Where the portal sends the customer back to. */ + returnUrl?: string; + /** Called with the portal URL. Defaults to redirecting. */ + onOpen?: (url: string) => void; + children?: ReactNode; + className?: string; +} + +/** + * Opens the payment provider's billing portal. Shown only to someone who can + * manage billing; a server without a portal answers with an error message. + */ +export function ManageBillingButton({ + returnUrl, + onOpen = redirect, + children = "Manage billing", + className, +}: ManageBillingButtonProps) { + const { canManage, openPortal, isPending } = useSubscription(); + const [failed, setFailed] = useState(false); + if (!canManage) return null; + + const open = async () => { + setFailed(false); + try { + onOpen(await openPortal(returnUrl)); + } catch { + setFailed(true); + } + }; + + return ( +
+ + {failed && Couldn't open the billing portal.} +
+ ); +} From 4d9100e5c941e4d3382dc6b9a723ec438090f7eb Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Tue, 10 Nov 2026 10:12:57 -0600 Subject: [PATCH 04/12] 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() 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. --- .../packages/authsome_subscription/LICENSE | 21 ++ .../analysis_options.yaml | 7 + .../lib/authsome_subscription.dart | 16 + .../authsome_subscription/lib/src/models.dart | 261 ++++++++++++++++ .../lib/src/subscription_api.dart | 295 ++++++++++++++++++ .../authsome_subscription/pubspec.yaml | 19 ++ .../test/subscription_api_test.dart | 280 +++++++++++++++++ flutter/pubspec.yaml | 1 + 8 files changed, 900 insertions(+) create mode 100644 flutter/packages/authsome_subscription/LICENSE create mode 100644 flutter/packages/authsome_subscription/analysis_options.yaml create mode 100644 flutter/packages/authsome_subscription/lib/authsome_subscription.dart create mode 100644 flutter/packages/authsome_subscription/lib/src/models.dart create mode 100644 flutter/packages/authsome_subscription/lib/src/subscription_api.dart create mode 100644 flutter/packages/authsome_subscription/pubspec.yaml create mode 100644 flutter/packages/authsome_subscription/test/subscription_api_test.dart 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/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..b4b8c38a --- /dev/null +++ b/flutter/packages/authsome_subscription/lib/src/models.dart @@ -0,0 +1,261 @@ +/// 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 with no minor unit. Everything else is treated as two places. +const _zeroDecimal = {'bif', 'clp', 'djf', 'gnf', 'jpy', 'kmf', 'krw', 'mga', 'pyg', 'rwf', 'ugx', 'vnd', 'vuv', 'xaf', 'xof', 'xpf'}; + +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 => amountMinor / _pow10(decimals); + + /// 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() { + final amount = major.toStringAsFixed(decimals); + final symbol = _symbols[currency.toLowerCase()]; + return symbol != null ? '$symbol$amount' : '${currency.toUpperCase()} $amount'; + } + + static int _pow10(int n) => n == 0 ? 1 : 100; +} + +/// 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..3c69beca --- /dev/null +++ b/flutter/packages/authsome_subscription/lib/src/subscription_api.dart @@ -0,0 +1,295 @@ +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; + + Resource copyWith({ResourceStatus? status, T? data, Object? error}) => + Resource(status ?? this.status, data ?? this.data, error); +} + +/// 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> entitlements; + + SubscriptionSnapshot copyWith({ + Resource? subscription, + bool? canManage, + Resource>? plans, + Resource>? entitlements, + }) => + SubscriptionSnapshot( + subscription: subscription ?? this.subscription, + canManage: canManage ?? this.canManage, + plans: plans ?? this.plans, + entitlements: entitlements ?? this.entitlements, + ); +} + +/// What a plan or seat change did. +sealed class ChangeResult { + const ChangeResult(); +} + +/// The change happened; here's the subscription now. +class SubscriptionChanged extends ChangeResult { + const SubscriptionChanged(this.subscription); + final Subscription subscription; +} + +/// Payment comes first: send the customer to [url]. Nothing changed yet. +class CheckoutRequired extends ChangeResult { + const CheckoutRequired(this.url); + final Uri url; +} + +/// When a cancellation takes effect. +enum CancelWhen { periodEnd, now } + +/// The subscription plugin's API, from `auth.plugin()` or, +/// in Flutter, `context.subscription`. +class SubscriptionApi { + SubscriptionApi(this._ctx) { + // 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((event) { + switch (event) { + case AuthEvent.signedOut: + _generation++; + _set(SubscriptionSnapshot.initial()); + case AuthEvent.signedIn || AuthEvent.orgChanged: + _generation++; + if (_snapshot.subscription.status != ResourceStatus.idle) { + unawaited(load()); + } + case AuthEvent.sessionRefreshed: + break; + } + }); + } + + final PluginContext _ctx; + late final StreamSubscription _events; + final _changes = StreamController.broadcast(); + SubscriptionSnapshot _snapshot = SubscriptionSnapshot.initial(); + + // Bumped whenever the session or organization changes, so a response for + // the one the user just left is dropped instead of shown. + int _generation = 0; + + /// The current snapshot. + SubscriptionSnapshot get snapshot => _snapshot; + + /// Every new snapshot. + Stream get changes => _changes.stream; + + void _set(SubscriptionSnapshot next) { + _snapshot = next; + if (!_changes.isClosed) _changes.add(next); + } + + bool get _unavailable => _ctx.isServerEnabled == false; + bool get _signedIn => _ctx.state is AuthAuthenticated; + + /// Loads the subscription and its entitlements. + Future load() async { + if (_unavailable) { + _set(_snapshot.copyWith( + subscription: const Resource(ResourceStatus.unavailable, null), + entitlements: const Resource(ResourceStatus.unavailable, {}), + )); + return; + } + if (!_signedIn) return; + final gen = _generation; + _set(_snapshot.copyWith( + subscription: Resource(ResourceStatus.loading, _snapshot.subscription.data), + )); + final entitlements = _loadEntitlements(gen); + try { + final res = await _ctx.request('GET', '/v1/billing/subscription') as Map; + if (gen != _generation) return; + final sub = res['subscription'] as Map?; + _set(_snapshot.copyWith( + subscription: Resource( + ResourceStatus.ready, + sub == null ? null : Subscription.fromJson(Map.from(sub)), + ), + canManage: res['can_manage'] as bool? ?? false, + )); + } catch (error) { + if (gen != _generation) return; + _set(_snapshot.copyWith( + subscription: Resource(ResourceStatus.error, _snapshot.subscription.data, error), + )); + } + await entitlements; + } + + Future _loadEntitlements(int gen) async { + _set(_snapshot.copyWith( + entitlements: Resource(ResourceStatus.loading, _snapshot.entitlements.data), + )); + try { + final res = await _ctx.request('GET', '/v1/billing/entitlements') as Map; + if (gen != _generation) return; + final raw = Map.from(res['entitlements'] as Map? ?? const {}); + _set(_snapshot.copyWith( + entitlements: Resource(ResourceStatus.ready, { + for (final e in raw.entries) + e.key: Entitlement.fromJson(Map.from(e.value as Map)), + }), + )); + } catch (error) { + if (gen != _generation) return; + _set(_snapshot.copyWith( + entitlements: Resource(ResourceStatus.error, _snapshot.entitlements.data, error), + )); + } + } + + /// Loads the plans the tenant can buy. + Future loadPlans() async { + if (_unavailable) { + _set(_snapshot.copyWith(plans: const Resource(ResourceStatus.unavailable, []))); + return; + } + // /plans is app-wide and wants the app's ID: the client config carries + // it, and so does the signed-in user. + final state = _ctx.state; + final user = state is AuthAuthenticated ? state.user : null; + final appId = _ctx.clientConfig?.appId ?? (user is User ? user.appId : null); + _set(_snapshot.copyWith(plans: Resource(ResourceStatus.loading, _snapshot.plans.data))); + try { + final res = await _ctx.request('GET', '/v1/billing/plans', query: {'app_id': appId}) as Map; + _set(_snapshot.copyWith( + plans: Resource(ResourceStatus.ready, [ + for (final p in (res['plans'] as List? ?? const [])) + Plan.fromJson(Map.from(p as Map)), + ]), + )); + } catch (error) { + _set(_snapshot.copyWith(plans: Resource(ResourceStatus.error, _snapshot.plans.data, error))); + } + } + + /// Starts a subscription on a plan, by slug or ID. + Future subscribe(String plan, {int? seats, String? returnUrl, String? cancelUrl}) => + _change('POST', _body({'plan': plan}, seats, returnUrl, cancelUrl)); + + /// Moves the subscription to another plan, by slug or ID. + Future changePlan(String plan, {int? seats, String? returnUrl, String? cancelUrl}) => + _change('PATCH', _body({'plan': plan}, seats, returnUrl, cancelUrl)); + + /// Changes the seat count on the current plan. + Future setSeats(int seats, {String? returnUrl, String? cancelUrl}) => + _change('PATCH', _body({}, seats, returnUrl, cancelUrl)); + + /// Cancels at the end of the period (the default) or now. + Future cancel({CancelWhen when = CancelWhen.periodEnd}) => _mutate( + '/v1/billing/subscription/cancel', + {'when': when == CancelWhen.now ? 'now' : 'period_end'}, + ); + + /// Takes back a cancellation scheduled for the end of the period. + Future resume() => _mutate('/v1/billing/subscription/resume', {}); + + /// A link to the payment provider's billing portal. + Future openPortal({String? returnUrl}) async { + final res = await _ctx.request('POST', '/v1/billing/portal', + body: {if (returnUrl != null) 'return_url': returnUrl}) as Map; + return Uri.parse(res['url'] as String); + } + + /// The cached check for a feature, null when it isn't on the plan or not loaded. + Entitlement? entitlement(String feature) => _snapshot.entitlements.data[feature]; + + Map _body(Map base, int? seats, String? returnUrl, String? cancelUrl) => { + ...base, + if (seats != null) 'seats': seats, + if (returnUrl != null) 'return_url': returnUrl, + if (cancelUrl != null) 'cancel_url': cancelUrl, + }; + + Future _change(String method, Map body) async { + final res = await _ctx.request(method, '/v1/billing/subscription', body: body) as Map; + final url = res['checkout_url'] as String?; + if (url != null && url.isNotEmpty) return CheckoutRequired(Uri.parse(url)); + return SubscriptionChanged(_commit(res)); + } + + Future _mutate(String path, Map body) async { + final res = await _ctx.request('POST', path, body: body) as Map; + return _commit(res); + } + + // Writes answer with the new subscription. It goes straight into the + // cache, and the entitlements are rechecked since the plan may have moved. + Subscription _commit(Map res) { + final json = res['subscription'] as Map?; + if (json == null) { + throw StateError('authsome: the server answered without a subscription'); + } + final sub = Subscription.fromJson(Map.from(json)); + _set(_snapshot.copyWith(subscription: Resource(ResourceStatus.ready, sub))); + unawaited(_loadEntitlements(_generation)); + return sub; + } + + /// Stops following the session. The plugin's manager calls nothing on + /// dispose today, so this is for tests and long-lived isolates. + Future close() async { + await _events.cancel(); + await _changes.close(); + } +} + +/// The subscription client plugin. Register it in [AuthConfig.plugins] and +/// read it with `auth.plugin()`. +class SubscriptionPlugin extends AuthPlugin { + const SubscriptionPlugin(); + + @override + String get name => 'subscription'; + + @override + SubscriptionApi init(PluginContext ctx) => SubscriptionApi(ctx); +} diff --git a/flutter/packages/authsome_subscription/pubspec.yaml b/flutter/packages/authsome_subscription/pubspec.yaml new file mode 100644 index 00000000..5d084cd2 --- /dev/null +++ b/flutter/packages/authsome_subscription/pubspec.yaml @@ -0,0 +1,19 @@ +name: authsome_subscription +description: Client plugin for authsome's subscription plugin. Plans, subscriptions, seats and entitlements for Dart and Flutter apps. +version: 1.6.1 +homepage: https://github.com/xraph/authsome +repository: https://github.com/xraph/authsome/tree/main/flutter/packages/authsome_subscription +issue_tracker: https://github.com/xraph/authsome/issues + +environment: + sdk: ^3.6.0 + +resolution: workspace + +dependencies: + authsome_core: ^1.6.1 + +dev_dependencies: + http: ^1.2.0 + lints: ^4.0.0 + test: ^1.25.0 diff --git a/flutter/packages/authsome_subscription/test/subscription_api_test.dart b/flutter/packages/authsome_subscription/test/subscription_api_test.dart new file mode 100644 index 00000000..18ed5c5c --- /dev/null +++ b/flutter/packages/authsome_subscription/test/subscription_api_test.dart @@ -0,0 +1,280 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:authsome_core/authsome_core.dart'; +import 'package:authsome_subscription/authsome_subscription.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:test/test.dart'; + +Map planJson(String slug, [Map overrides = const {}]) => { + 'id': 'plan_$slug', + 'name': '${slug[0].toUpperCase()}${slug.substring(1)}', + 'slug': slug, + 'description': '', + 'currency': 'usd', + 'status': 'active', + 'trial_days': 0, + 'features_count': 1, + 'is_addon': false, + 'app_id': 'app_1', + 'base_amount': '29.00', + 'billing_period': 'monthly', + 'amount_minor': 2900, + 'features': [ + {'key': 'sso', 'name': 'SSO', 'type': 'boolean', 'limit': 1, 'soft_limit': false}, + ], + ...overrides, + }; + +Map subJson([Map overrides = const {}]) => { + 'id': 'sub_1', + 'tenant_id': 'org_1', + 'status': 'active', + 'plan': planJson('pro'), + 'current_period_start': '2026-11-01T00:00:00Z', + 'current_period_end': '2026-12-01T00:00:00Z', + 'trial_start': null, + 'trial_end': null, + 'cancel_at': null, + 'canceled_at': null, + 'paused_at': null, + 'app_id': 'app_1', + ...overrides, + }; + +final _me = { + 'id': 'usr_1', + 'app_id': 'app_user', + 'env_id': 'env_1', + 'email': 'a@test', + 'email_verified': true, + 'first_name': 'A', + 'last_name': 'B', + 'banned': false, + 'phone_verified': false, + 'roles': [], + 'active_org_id': 'org_1', + 'created_at': '2026-01-01T00:00:00Z', + 'updated_at': '2026-01-01T00:00:00Z', +}; + +typedef Handler = FutureOr Function(http.Request req); + +http.Response _json(Object? body, [int status = 200]) => http.Response(jsonEncode(body), status); + +({AuthManager manager, Map routes, List calls, SubscriptionApi Function() api}) + _setup({List? serverPlugins, String? configAppId}) { + final routes = { + 'GET /v1/me': (_) => _json(_me), + 'POST /v1/signout': (_) => _json({'status': 'ok'}), + 'GET /v1/billing/subscription': (_) => _json({'subscription': subJson(), 'can_manage': true}), + 'GET /v1/billing/entitlements': (_) => _json({ + 'entitlements': { + 'sso': {'allowed': true, 'feature': 'sso', 'used': 0, 'limit': 1, 'remaining': 1}, + }, + }), + }; + final calls = []; + final storage = MemoryTokenStorage() + ..setItem('authsome:session', jsonEncode({ + 'session_token': 'tok', + 'refresh_token': 'ref', + 'expires_at': DateTime.now().add(const Duration(hours: 1)).toUtc().toIso8601String(), + })); + final manager = AuthManager(AuthConfig( + baseUrl: 'https://api.test', + storage: storage, + plugins: [SubscriptionPlugin()], + initialClientConfig: serverPlugins == null && configAppId == null + ? null + : ClientConfig(supportedPlugins: serverPlugins, appId: configAppId), + httpClient: MockClient((req) async { + final key = '${req.method} ${req.url.path}'; + calls.add('$key${req.url.hasQuery ? '?${req.url.query}' : ''}'); + final handler = routes[key]; + if (handler == null) return _json({'error': 'no route $key'}, 404); + return handler(req); + }), + )); + addTearDown(manager.dispose); + return (manager: manager, routes: routes, calls: calls, api: () => manager.plugin()); +} + +void main() { + group('models', () { + test('maps the server view into typed models', () async { + final s = _setup(); + s.routes['GET /v1/billing/subscription'] = (_) => _json({ + 'can_manage': false, + 'subscription': subJson({ + 'status': 'trialing', + 'trial_end': DateTime.now().add(const Duration(days: 3)).toUtc().toIso8601String(), + 'cancel_at': '2026-12-01T00:00:00Z', + 'seats': {'feature_key': 'seats', 'used': 3, 'limit': null}, + 'plan': planJson('team', { + 'features': [ + {'key': 'api', 'name': 'API', 'type': 'metered', 'limit': null, 'soft_limit': true, 'period': 'monthly'}, + ], + }), + }), + }); + await s.manager.initialize(); + await s.api().load(); + + final snap = s.api().snapshot; + final sub = snap.subscription.data!; + expect(snap.subscription.status, ResourceStatus.ready); + expect(snap.canManage, isFalse); + expect(sub.plan.slug, 'team'); + expect(sub.plan.price!.amountMinor, 2900); + expect(sub.plan.price!.interval, 'monthly'); + expect(sub.plan.features.single.limit, isNull); + expect(sub.plan.features.single.softLimit, isTrue); + expect(sub.seats!.used, 3); + expect(sub.seats!.limit, isNull); + expect(sub.status, SubscriptionStatus.trialing); + expect(sub.isTrialing, isTrue); + expect(sub.isActive, isTrue); + expect(sub.willCancel, isTrue); + expect(sub.cancelAt, DateTime.utc(2026, 12, 1)); + expect(sub.trialDaysLeft, 3); + }); + + test('keeps an unknown status from a newer server', () { + expect(SubscriptionStatus.parse('frozen'), SubscriptionStatus.unknown); + expect(SubscriptionStatus.parse('past_due'), SubscriptionStatus.pastDue); + }); + + test('formats money by the currency digits', () { + expect(const Money(amountMinor: 2900, currency: 'usd').format(), r'$29.00'); + expect(const Money(amountMinor: 500, currency: 'jpy').format(), '¥500'); + expect(const Money(amountMinor: 1050, currency: 'ngn').format(), 'NGN 10.50'); + }); + }); + + group('loading', () { + test('loads the subscription, can_manage and entitlements', () async { + final s = _setup(); + await s.manager.initialize(); + await s.api().load(); + expect(s.api().snapshot.subscription.data!.id, 'sub_1'); + expect(s.api().snapshot.canManage, isTrue); + expect(s.api().snapshot.entitlements.status, ResourceStatus.ready); + expect(s.api().entitlement('sso')!.allowed, isTrue); + expect(s.api().entitlement('nope'), isNull); + }); + + test('is unavailable without asking when the server has no subscription plugin', () async { + final s = _setup(serverPlugins: ['organization']); + await s.manager.initialize(); + await s.api().load(); + expect(s.api().snapshot.subscription.status, ResourceStatus.unavailable); + expect(s.calls.where((c) => c.contains('/v1/billing')), isEmpty); + }); + + test('drops a response for the org the session has since left', () async { + final s = _setup(); + await s.manager.initialize(); + final gate = Completer(); + s.routes['GET /v1/billing/subscription'] = (_) async { + await gate.future; + return _json({'subscription': subJson(), 'can_manage': true}); + }; + final loading = s.api().load(); + + s.routes['POST /v1/me/switch-org'] = (_) => _json({'session_id': 's', 'org_id': 'org_2'}); + s.routes['GET /v1/billing/subscription'] = (_) => _json({'subscription': null, 'can_manage': true}); + await s.manager.switchOrg('org_2'); + gate.complete(); + await loading; + await Future.delayed(Duration.zero); + expect(s.api().snapshot.subscription.status, ResourceStatus.ready); + expect(s.api().snapshot.subscription.data, isNull); + }); + + test('resets on sign-out', () async { + final s = _setup(); + await s.manager.initialize(); + await s.api().load(); + await s.manager.signOut(); + expect(s.api().snapshot.subscription.status, ResourceStatus.idle); + expect(s.api().snapshot.subscription.data, isNull); + }); + + test('reads plans for the client config app, else the user app', () async { + final withConfig = _setup(configAppId: 'app_cfg'); + withConfig.routes['GET /v1/billing/plans'] = (_) => _json({ + 'plans': [planJson('free', {'amount_minor': null}), planJson('pro')], + }); + await withConfig.manager.initialize(); + await withConfig.api().loadPlans(); + expect(withConfig.calls, contains('GET /v1/billing/plans?app_id=app_cfg')); + expect(withConfig.api().snapshot.plans.data.map((p) => p.slug), ['free', 'pro']); + expect(withConfig.api().snapshot.plans.data.first.price, isNull); + + final fromUser = _setup(); + fromUser.routes['GET /v1/billing/plans'] = (_) => _json({'plans': []}); + await fromUser.manager.initialize(); + await fromUser.api().loadPlans(); + expect(fromUser.calls, contains('GET /v1/billing/plans?app_id=app_user')); + }); + }); + + group('writes', () { + test('writes a changed subscription into the cache', () async { + final s = _setup(); + await s.manager.initialize(); + await s.api().load(); + s.routes['PATCH /v1/billing/subscription'] = (req) { + expect(jsonDecode(req.body), {'plan': 'team', 'seats': 5}); + return _json({'subscription': subJson({'plan': planJson('team')})}); + }; + final result = await s.api().changePlan('team', seats: 5); + expect(result, isA()); + expect(s.api().snapshot.subscription.data!.plan.slug, 'team'); + }); + + test('leaves the cache alone when payment comes first', () async { + final s = _setup(); + await s.manager.initialize(); + await s.api().load(); + s.routes['POST /v1/billing/subscription'] = (_) => _json({'checkout_url': 'https://pay.example/1'}); + final result = await s.api().subscribe('business', returnUrl: 'https://app/billing'); + expect(result, isA()); + expect((result as CheckoutRequired).url, Uri.parse('https://pay.example/1')); + expect(s.api().snapshot.subscription.data!.plan.slug, 'pro'); + }); + + test('surfaces a refused write without touching the cache', () async { + final s = _setup(); + await s.manager.initialize(); + await s.api().load(); + s.routes['POST /v1/billing/subscription/cancel'] = (_) => _json({'error': 'nope'}, 403); + await expectLater(s.api().cancel(), throwsA(isA())); + expect(s.api().snapshot.subscription.data!.cancelAt, isNull); + }); + + test('cancels at the period end by default and resumes', () async { + final s = _setup(); + await s.manager.initialize(); + await s.api().load(); + s.routes['POST /v1/billing/subscription/cancel'] = (req) { + expect(jsonDecode(req.body), {'when': 'period_end'}); + return _json({'subscription': subJson({'cancel_at': '2026-12-01T00:00:00Z'})}); + }; + s.routes['POST /v1/billing/subscription/resume'] = (_) => _json({'subscription': subJson()}); + expect((await s.api().cancel()).willCancel, isTrue); + expect((await s.api().resume()).willCancel, isFalse); + }); + + test('emits a snapshot on every change', () async { + final s = _setup(); + await s.manager.initialize(); + final seen = []; + s.api().changes.listen(seen.add); + await s.api().load(); + expect(seen, isNotEmpty); + }); + }); +} diff --git a/flutter/pubspec.yaml b/flutter/pubspec.yaml index 61e8698a..763c2d22 100644 --- a/flutter/pubspec.yaml +++ b/flutter/pubspec.yaml @@ -8,4 +8,5 @@ workspace: - packages/authsome_core - packages/authsome_flutter - packages/authsome_flutter_ui + - packages/authsome_subscription - example From 05cb398406f6e6493880043fa680e116a9646d16 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Tue, 10 Nov 2026 10:16:34 -0600 Subject: [PATCH 05/12] 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. --- .../authsome_subscription_flutter/LICENSE | 21 + .../analysis_options.yaml | 10 + .../lib/authsome_subscription_flutter.dart | 18 + .../lib/src/subscription_widgets.dart | 404 ++++++++++++++++++ .../pubspec.yaml | 24 ++ .../test/widgets_test.dart | 213 +++++++++ flutter/pubspec.yaml | 1 + 7 files changed, 691 insertions(+) create mode 100644 flutter/packages/authsome_subscription_flutter/LICENSE create mode 100644 flutter/packages/authsome_subscription_flutter/analysis_options.yaml create mode 100644 flutter/packages/authsome_subscription_flutter/lib/authsome_subscription_flutter.dart create mode 100644 flutter/packages/authsome_subscription_flutter/lib/src/subscription_widgets.dart create mode 100644 flutter/packages/authsome_subscription_flutter/pubspec.yaml create mode 100644 flutter/packages/authsome_subscription_flutter/test/widgets_test.dart diff --git a/flutter/packages/authsome_subscription_flutter/LICENSE b/flutter/packages/authsome_subscription_flutter/LICENSE new file mode 100644 index 00000000..ba6a044b --- /dev/null +++ b/flutter/packages/authsome_subscription_flutter/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_flutter/analysis_options.yaml b/flutter/packages/authsome_subscription_flutter/analysis_options.yaml new file mode 100644 index 00000000..f190a220 --- /dev/null +++ b/flutter/packages/authsome_subscription_flutter/analysis_options.yaml @@ -0,0 +1,10 @@ +analyzer: + exclude: + - build/** +include: package:flutter_lints/flutter.yaml + +linter: + rules: + prefer_single_quotes: true + sort_constructors_first: false + avoid_dynamic_calls: false diff --git a/flutter/packages/authsome_subscription_flutter/lib/authsome_subscription_flutter.dart b/flutter/packages/authsome_subscription_flutter/lib/authsome_subscription_flutter.dart new file mode 100644 index 00000000..f6152d1c --- /dev/null +++ b/flutter/packages/authsome_subscription_flutter/lib/authsome_subscription_flutter.dart @@ -0,0 +1,18 @@ +/// Flutter widgets for authsome's subscription plugin. +/// +/// ```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()); +/// ``` +library authsome_subscription_flutter; + +export 'package:authsome_flutter/authsome_flutter.dart'; +export 'package:authsome_subscription/authsome_subscription.dart'; + +export 'src/subscription_widgets.dart'; diff --git a/flutter/packages/authsome_subscription_flutter/lib/src/subscription_widgets.dart b/flutter/packages/authsome_subscription_flutter/lib/src/subscription_widgets.dart new file mode 100644 index 00000000..a0513863 --- /dev/null +++ b/flutter/packages/authsome_subscription_flutter/lib/src/subscription_widgets.dart @@ -0,0 +1,404 @@ +import 'dart:async'; + +import 'package:authsome_flutter/authsome_flutter.dart'; +import 'package:authsome_subscription/authsome_subscription.dart'; +import 'package:flutter/material.dart'; + +/// Reaches the subscription plugin from the widget tree. +extension SubscriptionContext on BuildContext { + /// The subscription plugin's API. The plugin has to be in the + /// [AuthConfig.plugins] of the surrounding [AuthProvider]. + SubscriptionApi get subscription => AuthProvider.of(this).plugin(); +} + +/// Builds from the subscription plugin's snapshot and rebuilds when it +/// changes. Loads the subscription (and, with [loadPlans], the plans) the +/// first time it's built while signed in. +class SubscriptionBuilder extends StatefulWidget { + const SubscriptionBuilder({required this.builder, this.loadPlans = false, super.key}); + + final Widget Function(BuildContext context, SubscriptionSnapshot snapshot) builder; + final bool loadPlans; + + @override + State createState() => _SubscriptionBuilderState(); +} + +class _SubscriptionBuilderState extends State { + SubscriptionApi? _api; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final auth = AuthProvider.of(context); + final api = auth.plugin(); + _api = api; + if (!auth.isAuthenticated) return; + if (api.snapshot.subscription.status == ResourceStatus.idle) unawaited(api.load()); + if (widget.loadPlans && api.snapshot.plans.status == ResourceStatus.idle) { + unawaited(api.loadPlans()); + } + } + + @override + Widget build(BuildContext context) { + final api = _api!; + return StreamBuilder( + stream: api.changes, + initialData: api.snapshot, + builder: (context, _) => widget.builder(context, api.snapshot), + ); + } +} + +bool _isLoading(Resource r) => + r.status == ResourceStatus.loading || r.status == ResourceStatus.idle; + +/// Shows [child] only when the plan includes [feature]. Loading, an error, +/// or billing not being installed all show [fallback] (or [loading] while +/// the check loads), so a paywall never opens by accident. +class EntitlementGate extends StatelessWidget { + const EntitlementGate({ + required this.feature, + required this.child, + this.fallback = const SizedBox.shrink(), + this.loading, + super.key, + }); + + final String feature; + final Widget child; + final Widget fallback; + final Widget? loading; + + @override + Widget build(BuildContext context) { + return SubscriptionBuilder(builder: (context, snap) { + final entitlements = snap.entitlements; + if (entitlements.status == ResourceStatus.ready && + entitlements.data[feature]?.allowed == true) { + return child; + } + if (_isLoading(entitlements)) return loading ?? fallback; + return fallback; + }); + } +} + +String _date(BuildContext context, DateTime date) => + MaterialLocalizations.of(context).formatShortDate(date.toLocal()); + +void _showError(BuildContext context, Object error) { + final message = error is AuthClientException ? error.message : 'Something went wrong'; + ScaffoldMessenger.maybeOf(context)?.showSnackBar(SnackBar(content: Text(message))); +} + +/// The plans the tenant can buy, cheapest first, with a button on each. +/// +/// Without [onSelected] a tap subscribes or changes plan. When the plan +/// needs paying for, [onCheckout] gets the payment page's URL; open it with +/// your launcher of choice, e.g. `launchUrl(uri)` from url_launcher. +class PricingTable extends StatefulWidget { + const PricingTable({ + this.onSelected, + this.onCheckout, + this.highlight, + this.returnUrl, + this.cancelUrl, + super.key, + }); + + final ValueChanged? onSelected; + final ValueChanged? onCheckout; + + /// Plan slug to draw attention to. + final String? highlight; + final String? returnUrl; + final String? cancelUrl; + + @override + State createState() => _PricingTableState(); +} + +class _PricingTableState extends State { + bool _busy = false; + + Future _choose(SubscriptionApi api, Plan plan, bool hasSubscription) async { + final onSelected = widget.onSelected; + if (onSelected != null) { + onSelected(plan); + return; + } + setState(() => _busy = true); + try { + final result = hasSubscription + ? await api.changePlan(plan.slug, returnUrl: widget.returnUrl, cancelUrl: widget.cancelUrl) + : await api.subscribe(plan.slug, returnUrl: widget.returnUrl, cancelUrl: widget.cancelUrl); + if (result is CheckoutRequired && mounted) { + final onCheckout = widget.onCheckout; + if (onCheckout != null) { + onCheckout(result.url); + } else { + _showError(context, StateError('Set PricingTable.onCheckout to open the payment page')); + } + } + } catch (error) { + if (mounted) _showError(context, error); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return SubscriptionBuilder( + loadPlans: true, + builder: (context, snap) { + if (_isLoading(snap.plans)) { + return const Center(child: Padding(padding: EdgeInsets.all(24), child: CircularProgressIndicator())); + } + if (snap.plans.status == ResourceStatus.error) { + return const Text("Couldn't load plans."); + } + final current = snap.subscription.data; + final showButtons = snap.canManage || widget.onSelected != null; + return Wrap( + spacing: 16, + runSpacing: 16, + children: [ + for (final plan in snap.plans.data) + SizedBox( + width: 240, + child: Card( + shape: plan.slug == widget.highlight + ? RoundedRectangleBorder( + side: BorderSide(color: theme.colorScheme.primary, width: 2), + borderRadius: BorderRadius.circular(12), + ) + : null, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(plan.name, style: theme.textTheme.titleMedium), + const SizedBox(height: 8), + Text(plan.price?.format() ?? 'No charge', style: theme.textTheme.headlineSmall), + if (plan.price != null && plan.price!.interval.isNotEmpty) + Text('/ ${plan.price!.interval}', style: theme.textTheme.bodySmall), + if (plan.trialDays > 0 && current == null) ...[ + const SizedBox(height: 8), + Chip(label: Text('${plan.trialDays}-day trial')), + ], + const SizedBox(height: 8), + for (final f in plan.features) + Text( + f.type == 'boolean' + ? f.name + : '${f.name} ${f.limit == null ? 'unlimited' : 'up to ${f.limit}'}', + style: theme.textTheme.bodyMedium, + ), + if (showButtons) ...[ + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: current?.plan.id == plan.id + ? const OutlinedButton(onPressed: null, child: Text('Current plan')) + : FilledButton( + onPressed: _busy + ? null + : () => _choose(context.subscription, plan, current != null), + child: Text('Choose ${plan.name}'), + ), + ), + ], + ], + ), + ), + ), + ), + ], + ); + }, + ); + } +} + +/// The tenant's plan: name, price, when it renews or ends, seats, and +/// cancel or keep for someone who can manage billing. +class CurrentPlanCard extends StatefulWidget { + const CurrentPlanCard({super.key}); + + @override + State createState() => _CurrentPlanCardState(); +} + +class _CurrentPlanCardState extends State { + bool _busy = false; + + Future _run(Future Function() write) async { + setState(() => _busy = true); + try { + await write(); + } catch (error) { + if (mounted) _showError(context, error); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + String _status(BuildContext context, Subscription sub) { + if (sub.isPaused) return 'Paused'; + if (sub.willCancel) return 'Ends on ${_date(context, sub.cancelAt!)}'; + if (sub.isTrialing && sub.trialEnd != null) return 'Trial ends on ${_date(context, sub.trialEnd!)}'; + if (sub.status == SubscriptionStatus.pastDue) return 'Payment is overdue'; + return 'Renews on ${_date(context, sub.currentPeriodEnd)}'; + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return SubscriptionBuilder(builder: (context, snap) { + if (_isLoading(snap.subscription)) { + return const Card(child: Padding(padding: EdgeInsets.all(24), child: LinearProgressIndicator())); + } + if (snap.subscription.status == ResourceStatus.error) { + return const Text("Couldn't load your plan."); + } + final sub = snap.subscription.data; + if (sub == null) { + return const Card( + child: ListTile( + title: Text('No plan yet'), + subtitle: Text('Pick a plan to get started.'), + ), + ); + } + final api = context.subscription; + final seats = sub.seats; + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row(children: [ + Expanded(child: Text(sub.plan.name, style: theme.textTheme.titleLarge)), + if (sub.isTrialing) const Chip(label: Text('Trial')), + if (sub.status == SubscriptionStatus.pastDue) + Chip( + label: const Text('Past due'), + backgroundColor: theme.colorScheme.errorContainer, + ), + ]), + if (sub.plan.price != null) + Text('${sub.plan.price!.format()} / ${sub.plan.price!.interval}', + style: theme.textTheme.bodyMedium), + const SizedBox(height: 8), + Text(_status(context, sub)), + if (seats != null) + Text( + seats.limit == null ? '${seats.used} seats' : '${seats.used} of ${seats.limit} seats', + style: theme.textTheme.bodySmall, + ), + if (snap.canManage && !sub.isPaused) ...[ + const SizedBox(height: 12), + sub.willCancel + ? FilledButton( + onPressed: _busy ? null : () => _run(api.resume), + child: const Text('Keep plan'), + ) + : OutlinedButton( + onPressed: _busy ? null : () => _run(api.cancel), + child: const Text('Cancel plan'), + ), + ], + ], + ), + ), + ); + }); + } +} + +/// How much of a metered feature is used, against the plan's limit. +class UsageMeter extends StatelessWidget { + const UsageMeter({required this.feature, this.label, super.key}); + + final String feature; + + /// What to call it. Defaults to the feature key. + final String? label; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return SubscriptionBuilder(builder: (context, snap) { + if (_isLoading(snap.entitlements)) return const LinearProgressIndicator(); + final e = snap.entitlements.data[feature]; + if (e == null) return const SizedBox.shrink(); + final unlimited = e.limit < 0; + final value = unlimited || e.limit == 0 ? 0.0 : (e.used / e.limit).clamp(0.0, 1.0); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row(children: [ + Expanded(child: Text(label ?? feature)), + Text(unlimited ? '${e.used} used' : '${e.used} of ${e.limit}', style: theme.textTheme.bodySmall), + ]), + const SizedBox(height: 4), + LinearProgressIndicator( + value: value, + color: value >= 0.9 ? theme.colorScheme.error : null, + semanticsLabel: label ?? feature, + ), + ], + ); + }); + } +} + +/// Opens the payment provider's billing portal. Shown only to someone who +/// can manage billing. [onOpen] gets the portal URL; open it with your +/// launcher of choice. +class ManageBillingButton extends StatefulWidget { + const ManageBillingButton({required this.onOpen, this.returnUrl, this.child, super.key}); + + final ValueChanged onOpen; + final String? returnUrl; + final Widget? child; + + @override + State createState() => _ManageBillingButtonState(); +} + +class _ManageBillingButtonState extends State { + bool _busy = false; + + Future _open(SubscriptionApi api) async { + setState(() => _busy = true); + try { + widget.onOpen(await api.openPortal(returnUrl: widget.returnUrl)); + } catch (_) { + if (mounted) { + ScaffoldMessenger.maybeOf(context)?.showSnackBar( + const SnackBar(content: Text("Couldn't open the billing portal")), + ); + } + } finally { + if (mounted) setState(() => _busy = false); + } + } + + @override + Widget build(BuildContext context) { + return SubscriptionBuilder(builder: (context, snap) { + if (!snap.canManage) return const SizedBox.shrink(); + return OutlinedButton( + onPressed: _busy ? null : () => _open(context.subscription), + child: widget.child ?? const Text('Manage billing'), + ); + }); + } +} diff --git a/flutter/packages/authsome_subscription_flutter/pubspec.yaml b/flutter/packages/authsome_subscription_flutter/pubspec.yaml new file mode 100644 index 00000000..97816e45 --- /dev/null +++ b/flutter/packages/authsome_subscription_flutter/pubspec.yaml @@ -0,0 +1,24 @@ +name: authsome_subscription_flutter +description: Flutter widgets for authsome's subscription plugin. Pricing table, current plan card, usage meter, entitlement gate and billing portal button. +version: 1.6.1 +homepage: https://github.com/xraph/authsome +repository: https://github.com/xraph/authsome/tree/main/flutter/packages/authsome_subscription_flutter +issue_tracker: https://github.com/xraph/authsome/issues + +environment: + sdk: ^3.6.0 + flutter: ">=3.10.0" + +resolution: workspace + +dependencies: + flutter: + sdk: flutter + authsome_flutter: ^1.6.1 + authsome_subscription: ^1.6.1 + +dev_dependencies: + http: ^1.6.0 + flutter_lints: ^4.0.0 + flutter_test: + sdk: flutter diff --git a/flutter/packages/authsome_subscription_flutter/test/widgets_test.dart b/flutter/packages/authsome_subscription_flutter/test/widgets_test.dart new file mode 100644 index 00000000..05c79c9c --- /dev/null +++ b/flutter/packages/authsome_subscription_flutter/test/widgets_test.dart @@ -0,0 +1,213 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:authsome_subscription_flutter/authsome_subscription_flutter.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +Map planJson(String slug, [Map overrides = const {}]) => { + 'id': 'plan_$slug', + 'name': '${slug[0].toUpperCase()}${slug.substring(1)}', + 'slug': slug, + 'description': '', + 'currency': 'usd', + 'status': 'active', + 'trial_days': 0, + 'is_addon': false, + 'amount_minor': 2900, + 'billing_period': 'monthly', + 'features': [ + {'key': 'sso', 'name': 'SSO', 'type': 'boolean', 'limit': 1, 'soft_limit': false}, + ], + ...overrides, + }; + +Map subJson([Map overrides = const {}]) => { + 'id': 'sub_1', + 'tenant_id': 'org_1', + 'status': 'active', + 'plan': planJson('pro'), + 'current_period_start': '2026-11-01T12:00:00Z', + 'current_period_end': '2026-12-01T12:00:00Z', + 'cancel_at': null, + ...overrides, + }; + +typedef Handler = FutureOr Function(http.Request req); +http.Response _json(Object? body, [int status = 200]) => http.Response(jsonEncode(body), status); + +class _Harness { + _Harness() { + routes.addAll({ + 'GET /v1/me': (_) => _json({ + 'id': 'usr_1', 'app_id': 'app_1', 'env_id': 'env_1', 'email': 'a@test', + 'email_verified': true, 'first_name': 'A', 'last_name': 'B', 'banned': false, + 'phone_verified': false, 'roles': [], 'active_org_id': 'org_1', + 'created_at': '2026-01-01T00:00:00Z', 'updated_at': '2026-01-01T00:00:00Z', + }), + 'GET /v1/billing/subscription': (_) => _json({ + 'subscription': subJson({'seats': {'feature_key': 'seats', 'used': 3, 'limit': 10}}), + 'can_manage': true, + }), + 'GET /v1/billing/entitlements': (_) => _json({ + 'entitlements': { + 'sso': {'allowed': true, 'feature': 'sso', 'used': 2, 'limit': 10, 'remaining': 8}, + }, + }), + 'GET /v1/billing/plans': (_) => _json({ + 'plans': [ + planJson('free', {'amount_minor': null}), + planJson('pro'), + planJson('team', {'amount_minor': 9900}), + ], + }), + }); + } + + final routes = {}; + final calls = []; + + Future signedIn() async { + final storage = MemoryTokenStorage(); + await storage.setItem('authsome:session', jsonEncode({ + 'session_token': 'tok', + 'refresh_token': 'ref', + 'expires_at': DateTime.now().add(const Duration(hours: 1)).toUtc().toIso8601String(), + })); + final auth = AuthNotifier(AuthConfig( + baseUrl: 'https://api.test', + storage: storage, + plugins: const [SubscriptionPlugin()], + httpClient: MockClient((req) async { + final key = '${req.method} ${req.url.path}'; + calls.add(key); + final handler = routes[key]; + return handler == null ? _json({'error': 'no route $key'}, 404) : handler(req); + }), + )); + await auth.initialize(); + return auth; + } + + Future pump(WidgetTester tester, Widget child) async { + final auth = await tester.runAsync(signedIn); + await tester.pumpWidget(AuthProvider.test( + notifier: auth!, + child: MaterialApp(home: Scaffold(body: SingleChildScrollView(child: child))), + )); + await _settle(tester); + return auth; + } +} + +// The mock client answers on real async gaps, so settle with real time. +Future _settle(WidgetTester tester) async { + for (var i = 0; i < 5; i++) { + await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 10))); + await tester.pump(); + } +} + +void main() { + testWidgets('SubscriptionBuilder loads the subscription', (tester) async { + await _Harness().pump(tester, SubscriptionBuilder( + builder: (context, snap) => Text(snap.subscription.data?.plan.name ?? 'none'), + )); + expect(find.text('Pro'), findsOneWidget); + }); + + testWidgets('EntitlementGate shows the child only when the plan allows it', (tester) async { + final h = _Harness(); + await h.pump(tester, const Column(children: [ + EntitlementGate(feature: 'sso', fallback: Text('upgrade sso'), child: Text('sso settings')), + EntitlementGate(feature: 'audit', fallback: Text('upgrade audit'), child: Text('audit log')), + ])); + expect(find.text('sso settings'), findsOneWidget); + expect(find.text('audit log'), findsNothing); + expect(find.text('upgrade audit'), findsOneWidget); + }); + + testWidgets('EntitlementGate fails closed on an error', (tester) async { + final h = _Harness(); + h.routes['GET /v1/billing/entitlements'] = (_) => _json({'error': 'down'}, 500); + await h.pump(tester, const EntitlementGate(feature: 'sso', fallback: Text('upgrade'), child: Text('sso settings'))); + expect(find.text('sso settings'), findsNothing); + expect(find.text('upgrade'), findsOneWidget); + }); + + testWidgets('PricingTable lists plans and changes plan', (tester) async { + final h = _Harness(); + var patched = 0; + h.routes['PATCH /v1/billing/subscription'] = (_) { + patched++; + return _json({'subscription': subJson({'plan': planJson('team', {'amount_minor': 9900})})}); + }; + await h.pump(tester, const PricingTable()); + expect(find.text(r'$99.00'), findsOneWidget); + expect(find.text('No charge'), findsOneWidget); + await tester.tap(find.text('Choose Team')); + await _settle(tester); + expect(patched, 1); + expect(find.text('Choose Pro'), findsOneWidget); + }); + + testWidgets('PricingTable hands a checkout URL to onCheckout', (tester) async { + final h = _Harness(); + h.routes['GET /v1/billing/subscription'] = (_) => _json({'subscription': null, 'can_manage': true}); + h.routes['POST /v1/billing/subscription'] = (_) => _json({'checkout_url': 'https://pay.example/1'}); + Uri? opened; + await h.pump(tester, PricingTable(onCheckout: (uri) => opened = uri)); + await tester.tap(find.text('Choose Pro')); + await _settle(tester); + expect(opened, Uri.parse('https://pay.example/1')); + }); + + testWidgets('CurrentPlanCard says when a canceled plan ends and keeps it', (tester) async { + final h = _Harness(); + h.routes['GET /v1/billing/subscription'] = (_) => _json({ + 'subscription': subJson({'cancel_at': '2026-12-01T12:00:00Z'}), + 'can_manage': true, + }); + var resumed = 0; + h.routes['POST /v1/billing/subscription/resume'] = (_) { + resumed++; + return _json({'subscription': subJson()}); + }; + await h.pump(tester, const CurrentPlanCard()); + expect(find.textContaining('Ends on Dec 1, 2026'), findsOneWidget); + await tester.tap(find.text('Keep plan')); + await _settle(tester); + expect(resumed, 1); + expect(find.textContaining('Renews on'), findsOneWidget); + }); + + testWidgets('CurrentPlanCard shows seats and no buttons to a member', (tester) async { + final h = _Harness(); + h.routes['GET /v1/billing/subscription'] = (_) => _json({ + 'subscription': subJson({'seats': {'feature_key': 'seats', 'used': 3, 'limit': 10}}), + 'can_manage': false, + }); + await h.pump(tester, const CurrentPlanCard()); + expect(find.text('3 of 10 seats'), findsOneWidget); + expect(find.byType(FilledButton), findsNothing); + expect(find.byType(OutlinedButton), findsNothing); + }); + + testWidgets('UsageMeter shows use against the limit', (tester) async { + await _Harness().pump(tester, const UsageMeter(feature: 'sso', label: 'SSO connections')); + expect(find.text('2 of 10'), findsOneWidget); + expect(find.text('SSO connections'), findsOneWidget); + }); + + testWidgets('ManageBillingButton opens the portal', (tester) async { + final h = _Harness(); + h.routes['POST /v1/billing/portal'] = (_) => _json({'url': 'https://pay.example/portal'}); + Uri? opened; + await h.pump(tester, ManageBillingButton(onOpen: (uri) => opened = uri)); + await tester.tap(find.text('Manage billing')); + await _settle(tester); + expect(opened, Uri.parse('https://pay.example/portal')); + }); +} diff --git a/flutter/pubspec.yaml b/flutter/pubspec.yaml index 763c2d22..7e11c813 100644 --- a/flutter/pubspec.yaml +++ b/flutter/pubspec.yaml @@ -9,4 +9,5 @@ workspace: - packages/authsome_flutter - packages/authsome_flutter_ui - packages/authsome_subscription + - packages/authsome_subscription_flutter - example From 3362b557a0e2a78e253fdfe407393b197f222cee Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Tue, 10 Nov 2026 10:19:31 -0600 Subject: [PATCH 06/12] 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. --- sdkgen/Makefile | 12 +++++- sdkgen/cmd/main.go | 24 +++++++++--- sdkgen/dart/deprecate_test.go | 43 +++++++++++++++++++++ sdkgen/dart/generator.go | 10 +++++ sdkgen/dart/templates/client.dart.tmpl | 3 ++ sdkgen/openapi/deprecate.go | 29 ++++++++++++++ sdkgen/typescript/deprecate_test.go | 44 ++++++++++++++++++++++ sdkgen/typescript/generator.go | 9 +++++ sdkgen/typescript/templates/client.ts.tmpl | 3 ++ 9 files changed, 169 insertions(+), 8 deletions(-) create mode 100644 sdkgen/dart/deprecate_test.go create mode 100644 sdkgen/openapi/deprecate.go create mode 100644 sdkgen/typescript/deprecate_test.go diff --git a/sdkgen/Makefile b/sdkgen/Makefile index 3e03c8ff..26696e05 100644 --- a/sdkgen/Makefile +++ b/sdkgen/Makefile @@ -27,6 +27,12 @@ SPEC_FILE ?= spec.json UI_CORE_OVERRIDES = refreshTokens=refresh,deleteAccount=deleteMe,exportUserData=exportMyData,verifyMFARecovery=verifyRecoveryCode FLUTTER_CORE_OVERRIDES = refreshTokens=refresh,deleteAccount=deleteMe,exportUserData=exportMyData,verifyMFARecovery=verifyRecoveryCode +# The embedded core clients point billing callers at the subscription plugin +# packages, which own those routes now. The standalone SDKs keep them as is: +# operators call the billing routes from servers. +UI_CORE_DEPRECATIONS = Billing=Use @authsome/plugin-subscription instead. +FLUTTER_CORE_DEPRECATIONS = Billing=Use the authsome_subscription package instead. + SDKGEN_CMD = go run ./cmd SPECGEN_CMD = go run ./cmd/specgen @@ -66,13 +72,15 @@ generate-dart: dump-spec ## Generate Dart SDK only generate-ui-core: dump-spec ## Generate embedded client for ui-core package @echo "==> Generating embedded TypeScript client for ui-core..." $(SDKGEN_CMD) generate --lang=typescript --out=../ui/packages/core/src/generated \ - --from-spec=$(SPEC_FILE) --output-mode=embedded --method-overrides=$(UI_CORE_OVERRIDES) $(SDKGEN_FLAGS) + --from-spec=$(SPEC_FILE) --output-mode=embedded --method-overrides=$(UI_CORE_OVERRIDES) \ + --deprecate-tags="$(UI_CORE_DEPRECATIONS)" $(SDKGEN_FLAGS) @echo "==> Embedded client generated in ../ui/packages/core/src/generated/" generate-flutter-core: dump-spec ## Generate embedded client for Flutter authsome_core package @echo "==> Generating embedded Dart client for Flutter authsome_core..." $(SDKGEN_CMD) generate --lang=dart --out=../flutter/packages/authsome_core/lib/src/generated \ - --from-spec=$(SPEC_FILE) --output-mode=embedded --method-overrides=$(FLUTTER_CORE_OVERRIDES) $(SDKGEN_FLAGS) + --from-spec=$(SPEC_FILE) --output-mode=embedded --method-overrides=$(FLUTTER_CORE_OVERRIDES) \ + --deprecate-tags="$(FLUTTER_CORE_DEPRECATIONS)" $(SDKGEN_FLAGS) @echo "==> Embedded Dart client generated in ../flutter/packages/authsome_core/lib/src/generated/" clean: ## Remove generated SDK output and spec file diff --git a/sdkgen/cmd/main.go b/sdkgen/cmd/main.go index 66b2b408..bf1023a6 100644 --- a/sdkgen/cmd/main.go +++ b/sdkgen/cmd/main.go @@ -63,6 +63,7 @@ func main() { outputMode := fs.String("output-mode", "standalone", "Output mode: standalone or embedded") methodOverrides := fs.String("method-overrides", "", "Comma-separated operationID=name overrides") fromSpec := fs.String("from-spec", "", "Path to pre-generated OpenAPI spec JSON (skip internal generation)") + deprecateTags := fs.String("deprecate-tags", "", "Semicolon-separated tagPrefix=note rules marking operations deprecated (e.g. \"Billing=Use @authsome/plugin-subscription instead.\")") if err := fs.Parse(os.Args[2:]); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) @@ -89,6 +90,15 @@ func main() { } } + // Parse deprecation rules + deprecated := make(map[string]string) + for _, rule := range strings.Split(*deprecateTags, ";") { + parts := strings.SplitN(strings.TrimSpace(rule), "=", 2) + if len(parts) == 2 && parts[0] != "" { + deprecated[parts[0]] = strings.TrimSpace(parts[1]) + } + } + // Load or generate OpenAPI spec. var spec *openapi.Spec if *fromSpec != "" { @@ -113,7 +123,7 @@ func main() { // Generate SDKs switch strings.ToLower(*lang) { case "typescript", "ts": - if err := generateTypeScript(spec, *outDir, *outputMode, overrides); err != nil { + if err := generateTypeScript(spec, *outDir, *outputMode, overrides, deprecated); err != nil { fmt.Fprintf(os.Stderr, "Error generating TypeScript SDK: %v\n", err) os.Exit(1) } @@ -123,12 +133,12 @@ func main() { os.Exit(1) } case "dart", "flutter": - if err := generateDart(spec, *outDir, *outputMode, overrides); err != nil { + if err := generateDart(spec, *outDir, *outputMode, overrides, deprecated); err != nil { fmt.Fprintf(os.Stderr, "Error generating Dart SDK: %v\n", err) os.Exit(1) } case "all": - if err := generateTypeScript(spec, filepath.Join(*outDir, "typescript"), *outputMode, overrides); err != nil { + if err := generateTypeScript(spec, filepath.Join(*outDir, "typescript"), *outputMode, overrides, deprecated); err != nil { fmt.Fprintf(os.Stderr, "Error generating TypeScript SDK: %v\n", err) os.Exit(1) } @@ -136,7 +146,7 @@ func main() { fmt.Fprintf(os.Stderr, "Error generating Go SDK: %v\n", err) os.Exit(1) } - if err := generateDart(spec, filepath.Join(*outDir, "dart"), *outputMode, overrides); err != nil { + if err := generateDart(spec, filepath.Join(*outDir, "dart"), *outputMode, overrides, deprecated); err != nil { fmt.Fprintf(os.Stderr, "Error generating Dart SDK: %v\n", err) os.Exit(1) } @@ -148,12 +158,13 @@ func main() { fmt.Println("Done!") } -func generateTypeScript(spec *openapi.Spec, outDir, outputMode string, overrides map[string]string) error { +func generateTypeScript(spec *openapi.Spec, outDir, outputMode string, overrides, deprecated map[string]string) error { fmt.Printf("Generating TypeScript SDK -> %s (mode: %s)\n", outDir, outputMode) gen := typescript.NewGenerator(typescript.GeneratorConfig{ OutputMode: outputMode, MethodOverrides: overrides, + DeprecatedTags: deprecated, }) files, err := gen.Generate(spec) if err != nil { @@ -163,12 +174,13 @@ func generateTypeScript(spec *openapi.Spec, outDir, outputMode string, overrides return writeFiles(outDir, files) } -func generateDart(spec *openapi.Spec, outDir, outputMode string, overrides map[string]string) error { +func generateDart(spec *openapi.Spec, outDir, outputMode string, overrides, deprecated map[string]string) error { fmt.Printf("Generating Dart SDK -> %s (mode: %s)\n", outDir, outputMode) gen := dart.NewGenerator(dart.GeneratorConfig{ OutputMode: outputMode, MethodOverrides: overrides, + DeprecatedTags: deprecated, }) files, err := gen.Generate(spec) if err != nil { diff --git a/sdkgen/dart/deprecate_test.go b/sdkgen/dart/deprecate_test.go new file mode 100644 index 00000000..555c7e2b --- /dev/null +++ b/sdkgen/dart/deprecate_test.go @@ -0,0 +1,43 @@ +package dart_test + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/xraph/authsome/sdkgen/dart" + "github.com/xraph/authsome/sdkgen/openapi" +) + +// Operations whose tag starts with a deprecated prefix get @Deprecated; +// everything else is left alone. +func TestGenerate_DeprecatesTaggedOperations(t *testing.T) { + spec, err := openapi.LoadSpecFromFile("../spec.json") + require.NoError(t, err) + + gen := dart.NewGenerator(dart.GeneratorConfig{ + OutputMode: "embedded", + DeprecatedTags: map[string]string{"Billing": "Use the authsome_subscription package instead."}, + }) + files, err := gen.Generate(spec) + require.NoError(t, err) + + var client string + for _, f := range files { + if strings.HasSuffix(f.Path, "api_client.dart") { + client = f.Content + } + } + require.NotEmpty(t, client) + + before := func(method string) string { + i := strings.Index(client, "> "+method+"(") + require.Greater(t, i, 0, method) + start := strings.LastIndex(client[:i], "\n\n") + return client[start:i] + } + assert.Contains(t, before("getActiveSubscription"), "@Deprecated('Use the authsome_subscription package instead.')") + assert.NotContains(t, before("getMe"), "@Deprecated") +} diff --git a/sdkgen/dart/generator.go b/sdkgen/dart/generator.go index fb178917..b74ce237 100644 --- a/sdkgen/dart/generator.go +++ b/sdkgen/dart/generator.go @@ -31,6 +31,12 @@ type GeneratorConfig struct { // MethodOverrides maps operationID to a custom method name. MethodOverrides map[string]string + + // DeprecatedTags marks every operation whose tag starts with a key as + // deprecated, with the value as the note. Used to point the embedded core + // client at the plugin package that now owns those routes. The note must + // not contain a single quote. + DeprecatedTags map[string]string } // Generator produces Dart SDK code from an OpenAPI spec. @@ -158,6 +164,9 @@ type OperationDef struct { AuthRequired bool PathParams []PathParamDef QueryParams []QueryParamDef + + // Deprecated is the deprecation note, empty when the operation isn't. + Deprecated string } // ── Build Template Data ───────────────────────────── @@ -239,6 +248,7 @@ func (g *Generator) buildTemplateData(spec *openapi.Spec) *TemplateData { Path: path, Summary: pair.op.Summary, } + opDef.Deprecated = openapi.DeprecationFor(pair.op.Tags, g.config.DeprecatedTags) // Determine if auth is required opDef.AuthRequired = openapi.OperationTakesToken(pair.op, spec.SecuritySchemes()) diff --git a/sdkgen/dart/templates/client.dart.tmpl b/sdkgen/dart/templates/client.dart.tmpl index c309ee0e..0205032f 100644 --- a/sdkgen/dart/templates/client.dart.tmpl +++ b/sdkgen/dart/templates/client.dart.tmpl @@ -71,6 +71,9 @@ class AuthClient { {{ range $op := .Operations }} /// {{ $op.Summary }} /// {{ $op.Method }} {{ $op.Path }} +{{- if $op.Deprecated }} + @Deprecated('{{ $op.Deprecated }}') +{{- end }} Future<{{ if isVoid $op.ResponseType }}void{{ else }}{{ $op.ResponseType }}{{ end }}> {{ $op.Name }}({{ buildDartParams $op }}) async { final path = {{ buildDartPath $op.Path $op.PathParams }}; {{- if hasQueryParams $op.QueryParams }} diff --git a/sdkgen/openapi/deprecate.go b/sdkgen/openapi/deprecate.go new file mode 100644 index 00000000..2d0a6d72 --- /dev/null +++ b/sdkgen/openapi/deprecate.go @@ -0,0 +1,29 @@ +package openapi + +import ( + "slices" + "strings" +) + +// DeprecationFor returns the note of the first rule whose tag prefix starts +// one of tags, or "" when none does. Rules map a tag prefix (e.g. "Billing") +// to the note shown to callers. Prefixes are tried in sorted order so the +// output is stable. +func DeprecationFor(tags []string, rules map[string]string) string { + if len(rules) == 0 { + return "" + } + prefixes := make([]string, 0, len(rules)) + for p := range rules { + prefixes = append(prefixes, p) + } + slices.Sort(prefixes) + for _, p := range prefixes { + for _, tag := range tags { + if strings.HasPrefix(tag, p) { + return rules[p] + } + } + } + return "" +} diff --git a/sdkgen/typescript/deprecate_test.go b/sdkgen/typescript/deprecate_test.go new file mode 100644 index 00000000..21bf6cd1 --- /dev/null +++ b/sdkgen/typescript/deprecate_test.go @@ -0,0 +1,44 @@ +package typescript_test + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/xraph/authsome/sdkgen/openapi" + "github.com/xraph/authsome/sdkgen/typescript" +) + +// Operations whose tag starts with a deprecated prefix get an @deprecated +// tag in their doc comment; everything else is left alone. +func TestGenerate_DeprecatesTaggedOperations(t *testing.T) { + spec, err := openapi.LoadSpecFromFile("../spec.json") + require.NoError(t, err) + + gen := typescript.NewGenerator(typescript.GeneratorConfig{ + OutputMode: "embedded", + DeprecatedTags: map[string]string{"Billing": "Use @authsome/plugin-subscription instead."}, + }) + files, err := gen.Generate(spec) + require.NoError(t, err) + + var client string + for _, f := range files { + if strings.HasSuffix(f.Path, "api-client.ts") { + client = f.Content + } + } + require.NotEmpty(t, client) + + docFor := func(method string) string { + i := strings.Index(client, " async "+method+"(") + require.Greater(t, i, 0, method) + start := strings.LastIndex(client[:i], "/**") + return client[start:i] + } + assert.Contains(t, docFor("getActiveSubscription"), "@deprecated Use @authsome/plugin-subscription instead.") + assert.Contains(t, docFor("subscribe"), "@deprecated") + assert.NotContains(t, docFor("getMe"), "@deprecated") +} diff --git a/sdkgen/typescript/generator.go b/sdkgen/typescript/generator.go index 09af7382..bea81f9e 100644 --- a/sdkgen/typescript/generator.go +++ b/sdkgen/typescript/generator.go @@ -32,6 +32,11 @@ type GeneratorConfig struct { // MethodOverrides maps operationID to a custom method name. // e.g., {"refreshSession": "refresh", "deleteAccount": "deleteMe"} MethodOverrides map[string]string + + // DeprecatedTags marks every operation whose tag starts with a key as + // deprecated, with the value as the note. Used to point the embedded core + // clients at the plugin package that now owns those routes. + DeprecatedTags map[string]string } // Generator produces TypeScript SDK code from an OpenAPI spec. @@ -186,6 +191,9 @@ type OperationDef struct { PathParams []PathParamDef QueryParams []QueryParamDef NeedsRequestAlias bool // true when the request type alias differs from an existing schema type + + // Deprecated is the deprecation note, empty when the operation isn't. + Deprecated string } func (g *Generator) buildTemplateData(spec *openapi.Spec) *TemplateData { @@ -271,6 +279,7 @@ func (g *Generator) buildTemplateData(spec *openapi.Spec) *TemplateData { Summary: pair.op.Summary, Tags: pair.op.Tags, } + opDef.Deprecated = openapi.DeprecationFor(pair.op.Tags, g.config.DeprecatedTags) // Determine if auth is required opDef.AuthRequired = openapi.OperationTakesToken(pair.op, spec.SecuritySchemes()) diff --git a/sdkgen/typescript/templates/client.ts.tmpl b/sdkgen/typescript/templates/client.ts.tmpl index 9ccf8294..6c3f98fe 100644 --- a/sdkgen/typescript/templates/client.ts.tmpl +++ b/sdkgen/typescript/templates/client.ts.tmpl @@ -161,6 +161,9 @@ export class AuthClient { /** * {{ $op.Summary }} * {{ $op.Method }} {{ $op.Path }} +{{- if $op.Deprecated }} + * @deprecated {{ $op.Deprecated }} +{{- end }} */ async {{ $op.Name }}({{ buildParams $op $.OutputMode }}): Promise<{{ $op.ResponseType }}> { {{- if hasQueryParams $op.QueryParams }} From c1ff861c4222272c1edf4d203a6d8cc988a9839a Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Tue, 10 Nov 2026 10:20:41 -0600 Subject: [PATCH 07/12] chore(sdk): deprecate the billing methods on the embedded core clients --- .../lib/src/generated/api_client.dart | 28 +++++++++++++++++++ ui/packages/core/src/generated/api-client.ts | 28 +++++++++++++++++++ 2 files changed, 56 insertions(+) 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..033a4276 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('Use the authsome_subscription package instead.') 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('Use the authsome_subscription package instead.') 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('Use the authsome_subscription package instead.') 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('Use the authsome_subscription package instead.') 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('Use the authsome_subscription package instead.') 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('Use the authsome_subscription package instead.') 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('Use the authsome_subscription package instead.') 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('Use the authsome_subscription package instead.') 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('Use the authsome_subscription package instead.') 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('Use the authsome_subscription package instead.') 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('Use the authsome_subscription package instead.') 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('Use the authsome_subscription package instead.') 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('Use the authsome_subscription package instead.') 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('Use the authsome_subscription package instead.') 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('Use the authsome_subscription package instead.') 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('Use the authsome_subscription package instead.') 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('Use the authsome_subscription package instead.') 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('Use the authsome_subscription package instead.') 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('Use the authsome_subscription package instead.') 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('Use the authsome_subscription package instead.') 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('Use the authsome_subscription package instead.') 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/ui/packages/core/src/generated/api-client.ts b/ui/packages/core/src/generated/api-client.ts index 3b6fa3f3..1b62eab9 100644 --- a/ui/packages/core/src/generated/api-client.ts +++ b/ui/packages/core/src/generated/api-client.ts @@ -1456,6 +1456,7 @@ export class AuthClient { /** * List coupons * GET /v1/billing/coupons + * @deprecated Use @authsome/plugin-subscription instead. */ async listCoupons(app_id: string, token: string): Promise { const params = new URLSearchParams(); @@ -1473,6 +1474,7 @@ export class AuthClient { /** * Create coupon * POST /v1/billing/coupons + * @deprecated Use @authsome/plugin-subscription instead. */ async createCoupon(body: CreateCouponRequest, token: string): Promise { const path = "/v1/billing/coupons"; @@ -1487,6 +1489,7 @@ export class AuthClient { /** * Delete coupon * DELETE /v1/billing/coupons/{couponId} + * @deprecated Use @authsome/plugin-subscription instead. */ async deleteCoupon(couponId: string, token: string): Promise { const path = `/v1/billing/coupons/${couponId}`; @@ -1501,6 +1504,7 @@ export class AuthClient { /** * List your entitlements * GET /v1/billing/entitlements + * @deprecated Use @authsome/plugin-subscription instead. */ async listEntitlements(token: string, tenant_id?: string): Promise { const params = new URLSearchParams(); @@ -1518,6 +1522,7 @@ export class AuthClient { /** * Check feature entitlement * GET /v1/billing/entitlements/{featureKey} + * @deprecated Use @authsome/plugin-subscription instead. */ async checkEntitlement(featureKey: string, tenant_id: string, token: string, app_id?: string): Promise { const params = new URLSearchParams(); @@ -1536,6 +1541,7 @@ export class AuthClient { /** * List invoices * GET /v1/billing/invoices + * @deprecated Use @authsome/plugin-subscription instead. */ async listInvoices(app_id: string, tenant_id: string, token: string): Promise { const params = new URLSearchParams(); @@ -1554,6 +1560,7 @@ export class AuthClient { /** * Get invoice * GET /v1/billing/invoices/{invoiceId} + * @deprecated Use @authsome/plugin-subscription instead. */ async getInvoice(invoiceId: string, token: string): Promise { const path = `/v1/billing/invoices/${invoiceId}`; @@ -1568,6 +1575,7 @@ export class AuthClient { /** * Mark invoice as paid * POST /v1/billing/invoices/{invoiceId}/pay + * @deprecated Use @authsome/plugin-subscription instead. */ async markInvoicePaid(invoiceId: string, body: MarkInvoicePaidRequest, token: string): Promise { const path = `/v1/billing/invoices/${invoiceId}/pay`; @@ -1582,6 +1590,7 @@ export class AuthClient { /** * Void an invoice * POST /v1/billing/invoices/{invoiceId}/void + * @deprecated Use @authsome/plugin-subscription instead. */ async voidInvoice(invoiceId: string, body: VoidInvoiceRequest, token: string): Promise { const path = `/v1/billing/invoices/${invoiceId}/void`; @@ -1596,6 +1605,7 @@ export class AuthClient { /** * List billing plans * GET /v1/billing/plans + * @deprecated Use @authsome/plugin-subscription instead. */ async listBillingPlans(app_id: string, token: string, status?: string, include_addons?: boolean): Promise { const params = new URLSearchParams(); @@ -1615,6 +1625,7 @@ export class AuthClient { /** * Create billing plan * POST /v1/billing/plans + * @deprecated Use @authsome/plugin-subscription instead. */ async createBillingPlan(body: CreateBillingPlanRequest, token: string): Promise { const path = "/v1/billing/plans"; @@ -1629,6 +1640,7 @@ export class AuthClient { /** * Get billing plan * GET /v1/billing/plans/{planId} + * @deprecated Use @authsome/plugin-subscription instead. */ async getBillingPlan(planId: string, token: string): Promise { const path = `/v1/billing/plans/${planId}`; @@ -1643,6 +1655,7 @@ export class AuthClient { /** * Activate a billing plan * POST /v1/billing/plans/{planId}/activate + * @deprecated Use @authsome/plugin-subscription instead. */ async activateBillingPlan(planId: string, token: string): Promise { const path = `/v1/billing/plans/${planId}/activate`; @@ -1657,6 +1670,7 @@ export class AuthClient { /** * Archive a billing plan * POST /v1/billing/plans/{planId}/archive + * @deprecated Use @authsome/plugin-subscription instead. */ async archiveBillingPlan(planId: string, token: string): Promise { const path = `/v1/billing/plans/${planId}/archive`; @@ -1671,6 +1685,7 @@ export class AuthClient { /** * Open the billing portal * POST /v1/billing/portal + * @deprecated Use @authsome/plugin-subscription instead. */ async createBillingPortalSession(body: CreateBillingPortalSessionRequest, token: string): Promise { const path = "/v1/billing/portal"; @@ -1685,6 +1700,7 @@ export class AuthClient { /** * Get your subscription * GET /v1/billing/subscription + * @deprecated Use @authsome/plugin-subscription instead. */ async getCurrentSubscription(token: string, tenant_id?: string): Promise { const params = new URLSearchParams(); @@ -1702,6 +1718,7 @@ export class AuthClient { /** * Subscribe to a plan * POST /v1/billing/subscription + * @deprecated Use @authsome/plugin-subscription instead. */ async subscribe(body: SubscribeRequest, token: string): Promise { const path = "/v1/billing/subscription"; @@ -1716,6 +1733,7 @@ export class AuthClient { /** * Change plan or seats * PATCH /v1/billing/subscription + * @deprecated Use @authsome/plugin-subscription instead. */ async updateSubscription(body: UpdateSubscriptionRequest, token: string): Promise { const path = "/v1/billing/subscription"; @@ -1730,6 +1748,7 @@ export class AuthClient { /** * Cancel your subscription * POST /v1/billing/subscription/cancel + * @deprecated Use @authsome/plugin-subscription instead. */ async cancelCurrentSubscription(body: CancelCurrentSubscriptionRequest, token: string): Promise { const path = "/v1/billing/subscription/cancel"; @@ -1744,6 +1763,7 @@ export class AuthClient { /** * Keep your subscription * POST /v1/billing/subscription/resume + * @deprecated Use @authsome/plugin-subscription instead. */ async resumeCurrentSubscription(body: ResumeCurrentSubscriptionRequest, token: string): Promise { const path = "/v1/billing/subscription/resume"; @@ -1758,6 +1778,7 @@ export class AuthClient { /** * List subscriptions * GET /v1/billing/subscriptions + * @deprecated Use @authsome/plugin-subscription instead. */ async listSubscriptions(app_id: string, tenant_id: string, token: string, status?: string): Promise { const params = new URLSearchParams(); @@ -1777,6 +1798,7 @@ export class AuthClient { /** * Create subscription * POST /v1/billing/subscriptions + * @deprecated Use @authsome/plugin-subscription instead. */ async createSubscription(body: CreateSubscriptionRequest, token: string): Promise { const path = "/v1/billing/subscriptions"; @@ -1791,6 +1813,7 @@ export class AuthClient { /** * Get active subscription * GET /v1/billing/subscriptions/active + * @deprecated Use @authsome/plugin-subscription instead. */ async getActiveSubscription(app_id: string, tenant_id: string, token: string): Promise { const params = new URLSearchParams(); @@ -1809,6 +1832,7 @@ export class AuthClient { /** * Cancel subscription * POST /v1/billing/subscriptions/{subId}/cancel + * @deprecated Use @authsome/plugin-subscription instead. */ async cancelSubscription(subId: string, body: CancelSubscriptionRequest, token: string): Promise { const path = `/v1/billing/subscriptions/${subId}/cancel`; @@ -1823,6 +1847,7 @@ export class AuthClient { /** * Change subscription plan * POST /v1/billing/subscriptions/{subId}/change-plan + * @deprecated Use @authsome/plugin-subscription instead. */ async changeSubscriptionPlan(subId: string, body: ChangeSubscriptionPlanRequest, token: string): Promise { const path = `/v1/billing/subscriptions/${subId}/change-plan`; @@ -1837,6 +1862,7 @@ export class AuthClient { /** * Pause subscription * POST /v1/billing/subscriptions/{subId}/pause + * @deprecated Use @authsome/plugin-subscription instead. */ async pauseSubscription(subId: string, token: string): Promise { const path = `/v1/billing/subscriptions/${subId}/pause`; @@ -1851,6 +1877,7 @@ export class AuthClient { /** * Resume subscription * POST /v1/billing/subscriptions/{subId}/resume + * @deprecated Use @authsome/plugin-subscription instead. */ async resumeSubscription(subId: string, token: string): Promise { const path = `/v1/billing/subscriptions/${subId}/resume`; @@ -1865,6 +1892,7 @@ export class AuthClient { /** * Get usage summary * GET /v1/billing/usage + * @deprecated Use @authsome/plugin-subscription instead. */ async getUsageSummary(app_id: string, tenant_id: string, token: string): Promise { const params = new URLSearchParams(); From 71e94e581d737b3c3b9a1ef129e551c4f6a089fd Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Tue, 10 Nov 2026 10:23:52 -0600 Subject: [PATCH 08/12] 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. --- .github/workflows/release.yml | 9 +- docs/content/docs/plugins/subscription.mdx | 52 +++++++++++ .../authsome_subscription/CHANGELOG.md | 5 ++ .../packages/authsome_subscription/README.md | 45 ++++++++++ .../CHANGELOG.md | 5 ++ .../authsome_subscription_flutter/README.md | 58 ++++++++++++ scripts/bump-flutter-version.sh | 6 +- scripts/prepare-npm-publish.sh | 4 +- ui/packages/plugin-subscription/README.md | 89 +++++++++++++++++++ 9 files changed, 268 insertions(+), 5 deletions(-) create mode 100644 flutter/packages/authsome_subscription/CHANGELOG.md create mode 100644 flutter/packages/authsome_subscription/README.md create mode 100644 flutter/packages/authsome_subscription_flutter/CHANGELOG.md create mode 100644 flutter/packages/authsome_subscription_flutter/README.md create mode 100644 ui/packages/plugin-subscription/README.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f502fd00..abdc3976 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: @@ -252,5 +259,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..b8cc0462 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. + +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_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/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_flutter/CHANGELOG.md b/flutter/packages/authsome_subscription_flutter/CHANGELOG.md new file mode 100644 index 00000000..62094844 --- /dev/null +++ b/flutter/packages/authsome_subscription_flutter/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 1.6.1 + +- First release. diff --git a/flutter/packages/authsome_subscription_flutter/README.md b/flutter/packages/authsome_subscription_flutter/README.md new file mode 100644 index 00000000..ccf09e01 --- /dev/null +++ b/flutter/packages/authsome_subscription_flutter/README.md @@ -0,0 +1,58 @@ +# authsome_subscription_flutter + +Material 3 widgets for authsome's `subscription` plugin: a pricing table, the current plan, usage, an entitlement gate and a billing portal button. + +```yaml +dependencies: + authsome_flutter: ^1.6.1 + authsome_subscription_flutter: ^1.6.1 +``` + +## Set it up + +```dart +AuthProvider( + config: AuthConfig( + baseUrl: 'https://api.example.com', + plugins: const [SubscriptionPlugin()], + ), + child: const MyApp(), +); +``` + +## Widgets + +```dart +const CurrentPlanCard(); + +PricingTable( + highlight: 'team', + onCheckout: (uri) => launchUrl(uri), +); + +const EntitlementGate( + feature: 'sso', + fallback: UpgradePrompt(), + child: SsoSettings(), +); + +const UsageMeter(feature: 'api_calls', label: 'API calls this month'); + +ManageBillingButton(onOpen: (uri) => launchUrl(uri)); +``` + +`EntitlementGate` only shows its child when the plan includes the feature. While the check loads, on an error, or when the server doesn't have the plugin, you get the fallback. + +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. + +For your own UI, `context.subscription` is the plugin's API and `SubscriptionBuilder` rebuilds from its snapshot: + +```dart +SubscriptionBuilder( + builder: (context, snap) { + final sub = snap.subscription.data; + if (snap.subscription.isLoading) return const CircularProgressIndicator(); + return Text(sub?.plan.name ?? 'Free'); + }, +); +``` diff --git a/scripts/bump-flutter-version.sh b/scripts/bump-flutter-version.sh index 502800d5..aaf52bd6 100755 --- a/scripts/bump-flutter-version.sh +++ b/scripts/bump-flutter-version.sh @@ -18,7 +18,7 @@ if ! printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+([-+].+)?$'; then fi PKG_DIR="$(cd "$(dirname "$0")/../flutter/packages" && pwd)" -PACKAGES=(authsome_core authsome_flutter authsome_flutter_ui) +PACKAGES=(authsome_core authsome_flutter authsome_flutter_ui authsome_subscription authsome_subscription_flutter) echo "Bumping Flutter packages to $VERSION..." @@ -29,11 +29,13 @@ for pkg in "${PACKAGES[@]}"; do echo " $pkg -> $VERSION" done -# Inter-package constraints track the release version: the three are published as +# Inter-package constraints track the release version: the packages are published as # a set, so a dependent never wants an older sibling. Anchored on leading # whitespace so the `name:` key at column 0 can't match. perl -pi -e "s{^(\s+authsome_core:).*}{\$1 ^$VERSION}" "$PKG_DIR/authsome_flutter/pubspec.yaml" perl -pi -e "s{^(\s+authsome_flutter:).*}{\$1 ^$VERSION}" "$PKG_DIR/authsome_flutter_ui/pubspec.yaml" +perl -pi -e "s{^(\s+authsome_core:).*}{\$1 ^$VERSION}" "$PKG_DIR/authsome_subscription/pubspec.yaml" +perl -pi -e "s{^(\s+authsome_(flutter|subscription):).*}{\$1 ^$VERSION}" "$PKG_DIR/authsome_subscription_flutter/pubspec.yaml" echo " inter-package constraints -> ^$VERSION" echo diff --git a/scripts/prepare-npm-publish.sh b/scripts/prepare-npm-publish.sh index 0ee7f28b..a54a2188 100755 --- a/scripts/prepare-npm-publish.sh +++ b/scripts/prepare-npm-publish.sh @@ -12,7 +12,7 @@ echo "Preparing npm packages for publish at version $VERSION..." # Replace workspace:* with ^VERSION first — npm itself doesn't understand the # workspace: protocol, so any npm invocation (including `npm version`) on a # package.json containing workspace:* fails with EUNSUPPORTEDPROTOCOL. -for pkg in ui/packages/react ui/packages/vue ui/packages/components ui/packages/nextjs; do +for pkg in ui/packages/react ui/packages/vue ui/packages/components ui/packages/nextjs ui/packages/plugin-subscription; do cd "$ROOT/$pkg" sed -i "s/\"workspace:\*\"/\"^$VERSION\"/g" package.json echo " Resolved workspace:* references in $pkg" @@ -26,7 +26,7 @@ done # including apps/storybook, whose package.json still uses the workspace: # protocol and trips EUNSUPPORTEDPROTOCOL. Edit package.json directly with # node to bypass that machinery; node is always present when npm is. -for pkg in ui/packages/core ui/packages/react ui/packages/vue ui/packages/components ui/packages/nextjs sdk/typescript; do +for pkg in ui/packages/core ui/packages/react ui/packages/vue ui/packages/components ui/packages/nextjs ui/packages/plugin-subscription sdk/typescript; do cd "$ROOT/$pkg" node -e "const fs=require('fs'); const p=JSON.parse(fs.readFileSync('package.json','utf8')); p.version='$VERSION'; fs.writeFileSync('package.json', JSON.stringify(p, null, 2) + '\n');" echo " Set $pkg version to $VERSION" diff --git a/ui/packages/plugin-subscription/README.md b/ui/packages/plugin-subscription/README.md new file mode 100644 index 00000000..ccd4ba73 --- /dev/null +++ b/ui/packages/plugin-subscription/README.md @@ -0,0 +1,89 @@ +# @authsome/plugin-subscription + +The client half of authsome's `subscription` plugin. You get the tenant's subscription, its plan, seats and feature checks as typed models, React hooks around them, and a pricing table you can drop in. + +```bash +npm install @authsome/plugin-subscription @authsome/ui-react +``` + +The server needs the plugin too: `authsome.WithPlugin(subscription.New(...))`. + +## Set it up + +```tsx +import { AuthProvider } from "@authsome/ui-react"; +import { subscription } from "@authsome/plugin-subscription"; + + + + +``` + +## Hooks + +```tsx +import { useSubscription, usePlans, Entitled } from "@authsome/plugin-subscription/react"; + +function PlanBadge() { + const { plan, subscription, isLoading } = useSubscription(); + if (isLoading) return ; + if (!subscription) return Free; + return {plan.name}{subscription.willCancel && " (ending)"}; +} +``` + +`useSubscription()` loads on first use and gives you the subscription, its plan, `canManage`, and every write: `subscribe`, `changePlan`, `setSeats`, `cancel`, `resume` and `openPortal`. Writes update every component reading the subscription, so you never refetch by hand. `isPending` is true while one is in flight. + +A plan change either happens or needs paying for first: + +```ts +const result = await changePlan("team"); +if (result.type === "checkout") window.location.assign(result.url); +``` + +`` shows its children only when the plan includes the feature. While the check loads, on an error, or when the server doesn't have the plugin, you get the fallback: + +```tsx +}> + + +``` + +`usePlans()` and `useEntitlement(feature)` cover the rest. + +## Components + +```tsx +import { + PricingTable, + CurrentPlanCard, + UsageMeter, + ManageBillingButton, +} from "@authsome/plugin-subscription/components"; + + + + + +``` + +They're built on `@authsome/ui-components`, so they pick up the same theme as the sign-in forms. Buttons only show for someone who can manage billing. If you use Tailwind 4, add the package to your sources the way you do for `@authsome/ui-components`: + +```css +@source "../node_modules/@authsome/plugin-subscription/dist"; +``` + +## Without React + +```ts +import { AuthManager } from "@authsome/ui-core"; +import { subscription } from "@authsome/plugin-subscription"; + +const auth = new AuthManager({ baseURL: url, plugins: [subscription()] }); +const billing = auth.plugin(subscription); + +await billing.load(); +billing.onChange(() => render(billing.getSnapshot())); +``` + +The snapshot follows the session: signing out clears it, and signing in or switching organization reloads it. A response for an organization you've already left is dropped rather than shown. From 89b6dec77df5a11b42335e28ea170509a9990a3e Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Tue, 10 Nov 2026 10:37:35 -0600 Subject: [PATCH 09/12] 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. --- ui/packages/plugin-subscription/package.json | 2 +- .../src/components.test.tsx | 25 +++++ .../plugin-subscription/src/components.tsx | 27 ++++-- ui/packages/plugin-subscription/src/index.ts | 3 +- ui/packages/plugin-subscription/src/models.ts | 25 +++-- .../plugin-subscription/src/plugin.test.ts | 89 ++++++++++++++++++ ui/packages/plugin-subscription/src/plugin.ts | 92 ++++++++++++++----- .../plugin-subscription/src/react.test.tsx | 36 ++++++++ ui/packages/plugin-subscription/src/react.tsx | 22 +++-- .../plugin-subscription/tsup.config.ts | 23 +++-- 10 files changed, 289 insertions(+), 55 deletions(-) diff --git a/ui/packages/plugin-subscription/package.json b/ui/packages/plugin-subscription/package.json index f3d02ecb..4f411eb3 100644 --- a/ui/packages/plugin-subscription/package.json +++ b/ui/packages/plugin-subscription/package.json @@ -32,7 +32,7 @@ "dist" ], "scripts": { - "build": "tsup", + "build": "rm -rf dist && tsup", "dev": "tsup --watch", "typecheck": "tsc --noEmit", "clean": "rm -rf dist", diff --git a/ui/packages/plugin-subscription/src/components.test.tsx b/ui/packages/plugin-subscription/src/components.test.tsx index 25c1bee8..a3cfb950 100644 --- a/ui/packages/plugin-subscription/src/components.test.tsx +++ b/ui/packages/plugin-subscription/src/components.test.tsx @@ -35,6 +35,20 @@ describe("PricingTable", () => { expect(onCheckout).toHaveBeenCalledWith("https://pay.example/s/1"); }); + it("shows plain words, not the server's message, when a change fails", async () => { + const h = harness(); + h.routes.set("PATCH /v1/billing/subscription", () => json({ error: "already subscribed: use PATCH to change plan" }, 409)); + await h.mount(); + const choose = await screen.findByRole("button", { name: "Choose Team" }); + await act(async () => { fireEvent.click(choose); }); + await waitFor(() => expect(screen.getByRole("alert").textContent).toBe("Couldn't change your plan. Try again.")); + }); + + it("says which plan is highlighted, not only with colour", async () => { + await harness().mount(); + await waitFor(() => expect(screen.getByText("Recommended")).toBeTruthy()); + }); + it("lets the app take over selection", async () => { const onSelect = vi.fn(); await harness().mount(); @@ -103,6 +117,17 @@ describe("UsageMeter", () => { await waitFor(() => expect(screen.getByText("2 of 10")).toBeTruthy()); expect(screen.getByText("SSO connections")).toBeTruthy(); expect(screen.getByRole("progressbar").getAttribute("aria-valuenow")).toBe("2"); + expect(screen.getByRole("progressbar").getAttribute("aria-valuetext")).toBe("2 of 10"); + }); + + it("describes unlimited use in words", async () => { + const h = harness(); + h.routes.set("GET /v1/billing/entitlements", () => json({ entitlements: { api: { allowed: true, feature: "api", used: 40, limit: -1, remaining: -1 } } })); + await h.mount(); + await waitFor(() => expect(screen.getByText("40 used")).toBeTruthy()); + const bar = screen.getByRole("progressbar"); + expect(bar.getAttribute("aria-valuetext")).toBe("40 used, no limit"); + expect(bar.getAttribute("aria-valuenow")).toBeNull(); }); }); diff --git a/ui/packages/plugin-subscription/src/components.tsx b/ui/packages/plugin-subscription/src/components.tsx index 325bc3e6..384cbe7a 100644 --- a/ui/packages/plugin-subscription/src/components.tsx +++ b/ui/packages/plugin-subscription/src/components.tsx @@ -57,6 +57,8 @@ export interface PricingTableProps { cancelUrl?: string; /** Locale for prices. Defaults to the browser's. */ locale?: string; + /** Called with the server's error when a change fails; the table shows plain words. */ + onError?: (error: unknown) => void; className?: string; } @@ -68,18 +70,19 @@ export function PricingTable({ returnUrl, cancelUrl, locale, + onError, className, }: PricingTableProps) { const { plans, isLoading, error } = usePlans(); const { subscription, canManage, subscribe, changePlan, isPending } = useSubscription(); - const [failed, setFailed] = useState(null); + const [failed, setFailed] = useState(false); const choose = async (plan: Plan) => { if (onSelect) { onSelect(plan); return; } - setFailed(null); + setFailed(false); try { const options = { returnUrl, cancelUrl }; const result: ChangeResult = subscription @@ -87,7 +90,8 @@ export function PricingTable({ : await subscribe(plan.slug, options); if (result.type === "checkout") onCheckout(result.url); } catch (err) { - setFailed(err instanceof Error ? err.message : "Couldn't change plan"); + setFailed(true); + onError?.(err); } }; @@ -111,7 +115,10 @@ export function PricingTable({ return ( - {plan.name} +
+ {plan.name} + {plan.slug === highlight && Recommended} +
{plan.description && {plan.description}}
@@ -158,7 +165,7 @@ export function PricingTable({ ); })}
- {failed && {failed}} + {failed && Couldn't change your plan. Try again.} ); } @@ -170,6 +177,8 @@ export interface CurrentPlanCardProps { locale?: string; /** Time zone for dates. Defaults to the browser's. */ timeZone?: string; + /** Called with the server's error when cancel or keep fails; the card shows plain words. */ + onError?: (error: unknown) => void; className?: string; } @@ -183,7 +192,7 @@ function statusLine(sub: Subscription, locale?: string, timeZone?: string): stri } /** The tenant's plan: name, price, when it renews or ends, seats, and cancel/keep. */ -export function CurrentPlanCard({ locale, timeZone, className }: CurrentPlanCardProps) { +export function CurrentPlanCard({ locale, timeZone, onError, className }: CurrentPlanCardProps) { const { subscription: sub, canManage, isLoading, error, cancel, resume, isPending } = useSubscription(); const [failed, setFailed] = useState(null); @@ -193,7 +202,8 @@ export function CurrentPlanCard({ locale, timeZone, className }: CurrentPlanCard try { await write(); } catch (err) { - setFailed(err instanceof Error ? err.message : "Something went wrong"); + setFailed("Couldn't update your plan. Try again."); + onError?.(err); } }; @@ -286,7 +296,8 @@ export function UsageMeter({ feature, label, className }: UsageMeterProps) { aria-label={label ?? feature} aria-valuemin={0} aria-valuemax={unlimited ? undefined : entitlement.limit} - aria-valuenow={entitlement.used} + aria-valuenow={unlimited ? undefined : entitlement.used} + aria-valuetext={unlimited ? `${entitlement.used} used, no limit` : `${entitlement.used} of ${entitlement.limit}`} className="h-2 w-full overflow-hidden rounded-full bg-secondary" >
{ expect(formatMoney({ amountMinor: 2900, currency: "usd" }, "en-US")).toBe("$29.00"); expect(formatMoney({ amountMinor: 500, currency: "jpy" }, "en-US")).toBe("¥500"); }); + + it("splits minor units the way the server's ledger does", () => { + // Ledger stores these with no decimals and everything else with two, + // whatever the currency's ISO digits are. + expect(formatMoney({ amountMinor: 5000, currency: "idr" }, "en-US")).toMatch(/5,000(?!\.)/); + expect(formatMoney({ amountMinor: 150, currency: "kwd" }, "en-US")).toMatch(/1\.50$/); + expect(formatMoney({ amountMinor: 1050, currency: "ugx" }, "en-US")).toMatch(/10\.50$/); + expect(formatMoney({ amountMinor: 1050, currency: "huf" }, "en-US")).toMatch(/10\.50$/); + }); }); describe("loading", () => { @@ -143,6 +152,46 @@ describe("loading", () => { expect(api().getSnapshot().subscription.data).toBeNull(); }); + it("shares one request between loads that overlap", async () => { + const { manager, api, calls } = setup(); + await manager.initialize(); + await Promise.all([api().load(), api().load(), api().load()]); + expect(calls.filter((c) => c.startsWith("GET /v1/billing/subscription"))).toHaveLength(1); + expect(calls.filter((c) => c.startsWith("GET /v1/billing/entitlements"))).toHaveLength(1); + }); + + it("forgets the old org's subscription as soon as the org switches", async () => { + const { manager, routes, api } = setup(); + await manager.initialize(); + await api().load(); + expect(api().getSnapshot().canManage).toBe(true); + routes.set("POST /v1/me/switch-org", () => json({ session_id: "s", org_id: "org_2" })); + routes.set("GET /v1/billing/subscription", () => new Promise(() => {})); + + await manager.switchOrg("org_2"); + const snap = api().getSnapshot(); + expect(snap.subscription.status).toBe("loading"); + expect(snap.subscription.data).toBeNull(); + expect(snap.canManage).toBe(false); + }); + + it("ignores a plans response that lands after sign-out", async () => { + const { manager, routes, api } = setup({ appId: "app_1" }); + await manager.initialize(); + let release!: () => void; + const gate = new Promise((r) => (release = r)); + routes.set("GET /v1/billing/plans", async () => { + await gate; + return json({ plans: [planJSON("pro")] }); + }); + const loading = api().loadPlans(); + await manager.signOut(); + release(); + await loading; + expect(api().getSnapshot().plans.status).toBe("idle"); + expect(api().getSnapshot().plans.data).toEqual([]); + }); + it("resets on sign-out and reloads on an org switch", async () => { const { manager, routes, api, calls } = setup(); await manager.initialize(); @@ -226,6 +275,46 @@ describe("writes", () => { expect((await api().resume()).willCancel).toBe(false); }); + it("keeps a write that lands after an org switch out of the new org's cache", async () => { + const { manager, routes, api } = setup(); + await manager.initialize(); + await api().load(); + let release!: () => void; + const gate = new Promise((r) => (release = r)); + routes.set("POST /v1/billing/subscription/cancel", async () => { + await gate; + return json({ subscription: subJSON({ cancel_at: "2026-12-01T00:00:00Z" }) }); + }); + const canceling = api().cancel(); + + routes.set("POST /v1/me/switch-org", () => json({ session_id: "s", org_id: "org_2" })); + routes.set("GET /v1/billing/subscription", () => json({ subscription: null, can_manage: true })); + await manager.switchOrg("org_2"); + await vi.waitFor(() => expect(api().getSnapshot().subscription.status).toBe("ready")); + release(); + + expect((await canceling).willCancel).toBe(true); + expect(api().getSnapshot().subscription.data).toBeNull(); + }); + + it("keeps a write that lands after sign-out out of the cache", async () => { + const { manager, routes, api } = setup(); + await manager.initialize(); + await api().load(); + let release!: () => void; + const gate = new Promise((r) => (release = r)); + routes.set("POST /v1/billing/subscription/resume", async () => { + await gate; + return json({ subscription: subJSON() }); + }); + const resuming = api().resume(); + await manager.signOut(); + release(); + await resuming; + expect(api().getSnapshot().subscription.status).toBe("idle"); + expect(api().getSnapshot().subscription.data).toBeNull(); + }); + it("notifies listeners on every change", async () => { const { manager, api } = setup(); await manager.initialize(); diff --git a/ui/packages/plugin-subscription/src/plugin.ts b/ui/packages/plugin-subscription/src/plugin.ts index 05c1f349..cd87ddf9 100644 --- a/ui/packages/plugin-subscription/src/plugin.ts +++ b/ui/packages/plugin-subscription/src/plugin.ts @@ -91,9 +91,16 @@ function asError(err: unknown): Error { export function createSubscriptionApi(ctx: PluginContext): SubscriptionApi { let snapshot = initialSnapshot(); const listeners = new Set<() => void>(); - // Bumped whenever the session or organization changes, so a response for - // the one the user just left is dropped instead of shown. + // Bumped whenever the session or organization changes. Anything that + // started before the bump (a load, a plans fetch, a write) finds a + // different number when it lands, and leaves the cache alone, so a + // response for the organization or user the session just left is never + // shown as the current one. let generation = 0; + // In-flight loads, shared by every caller in the same generation, so ten + // components mounting at once make one pair of requests. + let loading: { gen: number; promise: Promise } | null = null; + let loadingPlans: { gen: number; promise: Promise } | null = null; const set = (patch: Partial) => { snapshot = { ...snapshot, ...patch }; @@ -128,16 +135,7 @@ export function createSubscriptionApi(ctx: PluginContext): SubscriptionApi { } }; - const load = async () => { - if (unavailable()) { - set({ - subscription: { status: "unavailable", data: null, error: null }, - entitlements: { status: "unavailable", data: {}, error: null }, - }); - return; - } - if (!signedIn()) return; - const gen = generation; + const fetchSubscription = async (gen: number) => { set({ subscription: { ...snapshot.subscription, status: "loading", error: null } }); const entitlements = loadEntitlements(gen); try { @@ -161,11 +159,25 @@ export function createSubscriptionApi(ctx: PluginContext): SubscriptionApi { await entitlements; }; - const loadPlans = async () => { + const load = (): Promise => { if (unavailable()) { - set({ plans: { status: "unavailable", data: [], error: null } }); - return; + set({ + subscription: { status: "unavailable", data: null, error: null }, + entitlements: { status: "unavailable", data: {}, error: null }, + }); + return Promise.resolve(); } + if (!signedIn()) return Promise.resolve(); + if (loading && loading.gen === generation) return loading.promise; + const gen = generation; + const promise = fetchSubscription(gen).finally(() => { + if (loading?.promise === promise) loading = null; + }); + loading = { gen, promise }; + return promise; + }; + + const fetchPlans = async (gen: number) => { // /plans is app-wide and wants the app's ID: the client config carries // it, and so does the signed-in user. const state = ctx.getState(); @@ -177,19 +189,39 @@ export function createSubscriptionApi(ctx: PluginContext): SubscriptionApi { const res = await ctx.request<{ plans: PlanJSON[] }>("GET", "/v1/billing/plans", { query: { app_id: appId }, }); + if (gen !== generation) return; set({ plans: { status: "ready", data: (res.plans ?? []).map(toPlan), error: null } }); } catch (err) { + if (gen !== generation) return; set({ plans: { ...snapshot.plans, status: "error", error: asError(err) } }); } }; - // Writes answer with the new subscription. It goes straight into the - // cache, and the entitlements are rechecked since the plan may have moved. - const commit = (json: SubscriptionJSON | undefined): Subscription | null => { + const loadPlans = (): Promise => { + if (unavailable()) { + set({ plans: { status: "unavailable", data: [], error: null } }); + return Promise.resolve(); + } + if (loadingPlans && loadingPlans.gen === generation) return loadingPlans.promise; + const gen = generation; + const promise = fetchPlans(gen).finally(() => { + if (loadingPlans?.promise === promise) loadingPlans = null; + }); + loadingPlans = { gen, promise }; + return promise; + }; + + // Writes answer with the new subscription. It goes into the cache, and the + // entitlements are rechecked since the plan may have moved, unless the + // session changed while the write was out: then the caller still gets the + // result, but the cache now belongs to someone else. + const commit = (json: SubscriptionJSON | undefined, gen: number): Subscription | null => { if (!json) return null; const sub = toSubscription(json); - set({ subscription: { status: "ready", data: sub, error: null } }); - void loadEntitlements(generation); + if (gen === generation) { + set({ subscription: { status: "ready", data: sub, error: null } }); + void loadEntitlements(gen); + } return sub; }; @@ -197,13 +229,14 @@ export function createSubscriptionApi(ctx: PluginContext): SubscriptionApi { method: "POST" | "PATCH", body: Record, ): Promise => { + const gen = generation; const res = await ctx.request<{ subscription?: SubscriptionJSON; checkout_url?: string }>( method, "/v1/billing/subscription", { body }, ); if (res.checkout_url) return { type: "checkout", url: res.checkout_url }; - const sub = commit(res.subscription); + const sub = commit(res.subscription, gen); if (!sub) throw new Error("authsome: the server answered with neither a subscription nor a checkout URL"); return { type: "changed", subscription: sub }; }; @@ -216,14 +249,17 @@ export function createSubscriptionApi(ctx: PluginContext): SubscriptionApi { }); const mutate = async (path: string, body: unknown): Promise => { + const gen = generation; const res = await ctx.request<{ subscription?: SubscriptionJSON }>("POST", path, { body }); - const sub = commit(res.subscription); + const sub = commit(res.subscription, gen); if (!sub) throw new Error("authsome: the server answered without a subscription"); return sub; }; - // Follow the session: a sign-out forgets everything, and a sign-in or an - // organization switch reloads whatever had been loaded. + // Follow the session: a sign-out forgets everything; a sign-in or an + // organization switch drops the previous tenant's subscription at once + // (so no screen shows it, or offers buttons for it, while the new one + // loads) and reloads whatever had been loaded. ctx.on("signedOut", () => { generation++; snapshot = initialSnapshot(); @@ -231,7 +267,13 @@ export function createSubscriptionApi(ctx: PluginContext): SubscriptionApi { }); const reload = () => { generation++; - if (snapshot.subscription.status !== "idle") void load(); + if (snapshot.subscription.status === "idle") return; + set({ + subscription: { status: "loading", data: null, error: null }, + entitlements: { status: "loading", data: {}, error: null }, + canManage: false, + }); + void load(); }; ctx.on("signedIn", reload); ctx.on("orgChanged", reload); diff --git a/ui/packages/plugin-subscription/src/react.test.tsx b/ui/packages/plugin-subscription/src/react.test.tsx index 3d8e669d..d93ddf43 100644 --- a/ui/packages/plugin-subscription/src/react.test.tsx +++ b/ui/packages/plugin-subscription/src/react.test.tsx @@ -55,6 +55,42 @@ describe("useSubscription", () => { }); }); +describe("sharing", () => { + it("makes one request pair however many components read the subscription", async () => { + const h = harness(); + function Probe() { + useSubscription(); + return null; + } + await h.mount( + <> + + + + + , + ); + await waitFor(() => expect(h.calls.filter((c) => c === "GET /v1/billing/subscription")).toHaveLength(1)); + expect(h.calls.filter((c) => c === "GET /v1/billing/entitlements")).toHaveLength(1); + }); + + it("keeps the write functions stable between renders", async () => { + const h = harness(); + const seen: ReturnType[] = []; + function Probe() { + seen.push(useSubscription()); + return null; + } + await h.mount(); + await waitFor(() => expect(seen.at(-1)?.subscription).not.toBeNull()); + const first = seen[0]; + const last = seen.at(-1)!; + expect(last.changePlan).toBe(first.changePlan); + expect(last.cancel).toBe(first.cancel); + expect(last.refresh).toBe(first.refresh); + }); +}); + describe("usePlans", () => { it("loads the plans on mount", async () => { let seen = null as unknown as ReturnType; diff --git a/ui/packages/plugin-subscription/src/react.tsx b/ui/packages/plugin-subscription/src/react.tsx index 11324a3f..4ece143e 100644 --- a/ui/packages/plugin-subscription/src/react.tsx +++ b/ui/packages/plugin-subscription/src/react.tsx @@ -13,6 +13,7 @@ import { useAuth, usePlugin } from "@authsome/ui-react"; import { useCallback, useEffect, + useMemo, useState, useSyncExternalStore, type ReactNode, @@ -90,6 +91,20 @@ export function useSubscription(): UseSubscriptionResult { const signedIn = useAutoLoad(snap.subscription.status, api.load); const { isPending, track } = usePendingWrites(); + // Stable across renders, so they're safe in effect and memo dependencies. + const writes = useMemo( + () => ({ + subscribe: (plan: string, options?: ChangeOptions) => track(() => api.subscribe(plan, options)), + changePlan: (plan: string, options?: ChangeOptions) => track(() => api.changePlan(plan, options)), + setSeats: (seats: number, options?: Omit) => + track(() => api.setSeats(seats, options)), + cancel: (options?: { when?: "period_end" | "now" }) => track(() => api.cancel(options)), + resume: () => track(() => api.resume()), + openPortal: (returnUrl?: string) => track(() => api.openPortal(returnUrl)), + }), + [api, track], + ); + return { subscription: snap.subscription.data, plan: snap.subscription.data?.plan ?? null, @@ -98,12 +113,7 @@ export function useSubscription(): UseSubscriptionResult { isLoading: isPendingStatus(snap.subscription.status, signedIn), error: snap.subscription.error, refresh: api.load, - subscribe: (plan, options) => track(() => api.subscribe(plan, options)), - changePlan: (plan, options) => track(() => api.changePlan(plan, options)), - setSeats: (seats, options) => track(() => api.setSeats(seats, options)), - cancel: (options) => track(() => api.cancel(options)), - resume: () => track(() => api.resume()), - openPortal: (returnUrl) => track(() => api.openPortal(returnUrl)), + ...writes, isPending, }; } diff --git a/ui/packages/plugin-subscription/tsup.config.ts b/ui/packages/plugin-subscription/tsup.config.ts index 60ce7035..cb842c3c 100644 --- a/ui/packages/plugin-subscription/tsup.config.ts +++ b/ui/packages/plugin-subscription/tsup.config.ts @@ -1,13 +1,14 @@ -import { defineConfig } from "tsup"; +import { defineConfig, type Options } from "tsup"; // Three entries so an app pays only for what it imports: the framework-free // plugin, the React hooks, and the styled components built on -// @authsome/ui-components. -export default defineConfig({ - entry: ["src/index.ts", "src/react.tsx", "src/components.tsx"], +// @authsome/ui-components. Only the React entries are marked "use client": +// the plugin and its models stay importable from server components. The two +// builds run side by side, so neither cleans dist; the build script does. +const shared: Options = { format: ["esm"], dts: true, - clean: true, + clean: false, sourcemap: true, target: "es2022", external: [ @@ -17,7 +18,13 @@ export default defineConfig({ "@authsome/ui-react", "@authsome/ui-components", ], - banner: { - js: '"use client";', +}; + +export default defineConfig([ + { ...shared, entry: ["src/index.ts"] }, + { + ...shared, + entry: ["src/react.tsx", "src/components.tsx"], + banner: { js: '"use client";' }, }, -}); +]); From d2ab685b5061d0e701a53dabc651b6bdb9dd6d13 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Tue, 10 Nov 2026 10:40:45 -0600 Subject: [PATCH 10/12] 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. --- docs/content/docs/plugins/subscription.mdx | 2 +- .../authsome_subscription/lib/src/models.dart | 18 +-- .../lib/src/subscription_api.dart | 88 +++++++++----- .../test/subscription_api_test.dart | 107 ++++++++++++++++++ .../authsome_subscription_flutter/README.md | 2 + .../lib/src/subscription_widgets.dart | 54 ++++++--- .../test/widgets_test.dart | 78 ++++++++++++- 7 files changed, 296 insertions(+), 53 deletions(-) diff --git a/docs/content/docs/plugins/subscription.mdx b/docs/content/docs/plugins/subscription.mdx index b8cc0462..bcaea046 100644 --- a/docs/content/docs/plugins/subscription.mdx +++ b/docs/content/docs/plugins/subscription.mdx @@ -195,7 +195,7 @@ 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. +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. diff --git a/flutter/packages/authsome_subscription/lib/src/models.dart b/flutter/packages/authsome_subscription/lib/src/models.dart index b4b8c38a..200db0c4 100644 --- a/flutter/packages/authsome_subscription/lib/src/models.dart +++ b/flutter/packages/authsome_subscription/lib/src/models.dart @@ -23,8 +23,9 @@ enum SubscriptionStatus { }; } -/// Currencies with no minor unit. Everything else is treated as two places. -const _zeroDecimal = {'bif', 'clp', 'djf', 'gnf', 'jpy', 'kmf', 'krw', 'mga', 'pyg', 'rwf', 'ugx', 'vnd', 'vuv', 'xaf', 'xof', 'xpf'}; +/// 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$'}; @@ -39,18 +40,21 @@ class Money { 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 => amountMinor / _pow10(decimals); + 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() { - final amount = major.toStringAsFixed(decimals); + // 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 ? '$symbol$amount' : '${currency.toUpperCase()} $amount'; + return symbol != null ? '$sign$symbol$amount' : '$sign${currency.toUpperCase()} $amount'; } - - static int _pow10(int n) => n == 0 ? 1 : 100; } /// A plan's base price and how often it's charged. diff --git a/flutter/packages/authsome_subscription/lib/src/subscription_api.dart b/flutter/packages/authsome_subscription/lib/src/subscription_api.dart index 3c69beca..bd43f9ad 100644 --- a/flutter/packages/authsome_subscription/lib/src/subscription_api.dart +++ b/flutter/packages/authsome_subscription/lib/src/subscription_api.dart @@ -18,9 +18,6 @@ class Resource { final Object? error; bool get isLoading => status == ResourceStatus.loading; - - Resource copyWith({ResourceStatus? status, T? data, Object? error}) => - Resource(status ?? this.status, data ?? this.data, error); } /// Everything the plugin knows, as one immutable snapshot. @@ -90,20 +87,22 @@ class SubscriptionApi { SubscriptionApi(this._ctx) { // 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((event) { - switch (event) { - case AuthEvent.signedOut: - _generation++; - _set(SubscriptionSnapshot.initial()); - case AuthEvent.signedIn || AuthEvent.orgChanged: - _generation++; - if (_snapshot.subscription.status != ResourceStatus.idle) { - unawaited(load()); - } - case AuthEvent.sessionRefreshed: - break; - } - }); + _events = _ctx.events.listen( + (event) { + switch (event) { + case AuthEvent.signedOut: + _generation++; + _set(SubscriptionSnapshot.initial()); + case AuthEvent.signedIn || AuthEvent.orgChanged: + _reload(); + case AuthEvent.sessionRefreshed: + break; + } + }, + // The manager closes its events when it's disposed; nothing will + // change after that, so neither does this stream. + onDone: () => unawaited(_changes.close()), + ); } final PluginContext _ctx; @@ -115,6 +114,9 @@ class SubscriptionApi { // the one the user just left is dropped instead of shown. int _generation = 0; + // The load in flight for the current generation, shared by every caller. + ({int gen, Future future})? _loading; + /// The current snapshot. SubscriptionSnapshot get snapshot => _snapshot; @@ -129,8 +131,33 @@ class SubscriptionApi { bool get _unavailable => _ctx.isServerEnabled == false; bool get _signedIn => _ctx.state is AuthAuthenticated; - /// Loads the subscription and its entitlements. - Future load() async { + // A new session or organization: whatever was shown belongs to the old + // one, so clear it before anyone can act on it, then load again. + void _reload() { + _generation++; + if (_snapshot.subscription.status == ResourceStatus.idle) return; + _set(_snapshot.copyWith( + subscription: const Resource(ResourceStatus.loading, null), + entitlements: const Resource(ResourceStatus.loading, {}), + canManage: false, + )); + unawaited(load()); + } + + /// Loads the subscription and its entitlements. Calls made while a load + /// is running share it. + Future load() { + final current = _loading; + if (current != null && current.gen == _generation) return current.future; + final gen = _generation; + final future = _load(gen).whenComplete(() { + if (_loading?.gen == gen) _loading = null; + }); + _loading = (gen: gen, future: future); + return future; + } + + Future _load(int gen) async { if (_unavailable) { _set(_snapshot.copyWith( subscription: const Resource(ResourceStatus.unavailable, null), @@ -139,7 +166,6 @@ class SubscriptionApi { return; } if (!_signedIn) return; - final gen = _generation; _set(_snapshot.copyWith( subscription: Resource(ResourceStatus.loading, _snapshot.subscription.data), )); @@ -165,6 +191,7 @@ class SubscriptionApi { } Future _loadEntitlements(int gen) async { + if (gen != _generation) return; _set(_snapshot.copyWith( entitlements: Resource(ResourceStatus.loading, _snapshot.entitlements.data), )); @@ -197,9 +224,11 @@ class SubscriptionApi { final state = _ctx.state; final user = state is AuthAuthenticated ? state.user : null; final appId = _ctx.clientConfig?.appId ?? (user is User ? user.appId : null); + final gen = _generation; _set(_snapshot.copyWith(plans: Resource(ResourceStatus.loading, _snapshot.plans.data))); try { final res = await _ctx.request('GET', '/v1/billing/plans', query: {'app_id': appId}) as Map; + if (gen != _generation) return; _set(_snapshot.copyWith( plans: Resource(ResourceStatus.ready, [ for (final p in (res['plans'] as List? ?? const [])) @@ -207,6 +236,7 @@ class SubscriptionApi { ]), )); } catch (error) { + if (gen != _generation) return; _set(_snapshot.copyWith(plans: Resource(ResourceStatus.error, _snapshot.plans.data, error))); } } @@ -250,27 +280,33 @@ class SubscriptionApi { }; Future _change(String method, Map body) async { + final gen = _generation; final res = await _ctx.request(method, '/v1/billing/subscription', body: body) as Map; final url = res['checkout_url'] as String?; if (url != null && url.isNotEmpty) return CheckoutRequired(Uri.parse(url)); - return SubscriptionChanged(_commit(res)); + return SubscriptionChanged(_commit(res, gen)); } Future _mutate(String path, Map body) async { + final gen = _generation; final res = await _ctx.request('POST', path, body: body) as Map; - return _commit(res); + return _commit(res, gen); } // Writes answer with the new subscription. It goes straight into the // cache, and the entitlements are rechecked since the plan may have moved. - Subscription _commit(Map res) { + // A write that lands after the user switched org or signed out still + // returns its result, but the cache now belongs to someone else. + Subscription _commit(Map res, int gen) { final json = res['subscription'] as Map?; if (json == null) { throw StateError('authsome: the server answered without a subscription'); } final sub = Subscription.fromJson(Map.from(json)); - _set(_snapshot.copyWith(subscription: Resource(ResourceStatus.ready, sub))); - unawaited(_loadEntitlements(_generation)); + if (gen == _generation) { + _set(_snapshot.copyWith(subscription: Resource(ResourceStatus.ready, sub))); + unawaited(_loadEntitlements(gen)); + } return sub; } @@ -278,7 +314,7 @@ class SubscriptionApi { /// dispose today, so this is for tests and long-lived isolates. Future close() async { await _events.cancel(); - await _changes.close(); + if (!_changes.isClosed) await _changes.close(); } } diff --git a/flutter/packages/authsome_subscription/test/subscription_api_test.dart b/flutter/packages/authsome_subscription/test/subscription_api_test.dart index 18ed5c5c..5ea4c07f 100644 --- a/flutter/packages/authsome_subscription/test/subscription_api_test.dart +++ b/flutter/packages/authsome_subscription/test/subscription_api_test.dart @@ -151,6 +151,20 @@ void main() { expect(const Money(amountMinor: 500, currency: 'jpy').format(), '¥500'); expect(const Money(amountMinor: 1050, currency: 'ngn').format(), 'NGN 10.50'); }); + + test('uses the ledger currency digits', () { + // The ledger stores idr without a minor unit and every other + // currency outside its zero-decimal list with two places. + expect(const Money(amountMinor: 15000, currency: 'IDR').decimals, 0); + expect(const Money(amountMinor: 15000, currency: 'idr').format(), 'IDR 15000'); + expect(const Money(amountMinor: 1050, currency: 'bif').decimals, 2); + expect(const Money(amountMinor: 1050, currency: 'bif').format(), 'BIF 10.50'); + }); + + test('formats large and negative amounts exactly', () { + expect(const Money(amountMinor: 900719925474099, currency: 'usd').format(), r'$9007199254740.99'); + expect(const Money(amountMinor: -1005, currency: 'usd').format(), r'-$10.05'); + }); }); group('loading', () { @@ -193,6 +207,58 @@ void main() { expect(s.api().snapshot.subscription.data, isNull); }); + test('shares one request between overlapping loads', () async { + final s = _setup(); + await s.manager.initialize(); + await Future.wait([s.api().load(), s.api().load()]); + expect(s.calls.where((c) => c == 'GET /v1/billing/subscription'), hasLength(1)); + expect(s.calls.where((c) => c == 'GET /v1/billing/entitlements'), hasLength(1)); + }); + + test('hides the old org while the new one loads', () async { + final s = _setup(); + await s.manager.initialize(); + await s.api().load(); + final gate = Completer(); + s.routes['POST /v1/me/switch-org'] = (_) => _json({'session_id': 's', 'org_id': 'org_2'}); + s.routes['GET /v1/billing/subscription'] = (_) async { + await gate.future; + return _json({'subscription': null, 'can_manage': false}); + }; + await s.manager.switchOrg('org_2'); + final snap = s.api().snapshot; + expect(snap.subscription.status, ResourceStatus.loading); + expect(snap.subscription.data, isNull); + expect(snap.canManage, isFalse); + expect(snap.entitlements.data, isEmpty); + gate.complete(); + }); + + test('drops plans that arrive after sign-out', () async { + final s = _setup(); + await s.manager.initialize(); + final gate = Completer(); + s.routes['GET /v1/billing/plans'] = (_) async { + await gate.future; + return _json({'plans': [planJson('pro')]}); + }; + final loading = s.api().loadPlans(); + await s.manager.signOut(); + gate.complete(); + await loading; + expect(s.api().snapshot.plans.status, ResourceStatus.idle); + expect(s.api().snapshot.plans.data, isEmpty); + }); + + test('ends the changes stream when the manager is disposed', () async { + final s = _setup(); + await s.manager.initialize(); + final done = Completer(); + s.api().changes.listen(null, onDone: done.complete); + s.manager.dispose(); + await done.future.timeout(const Duration(seconds: 1)); + }); + test('resets on sign-out', () async { final s = _setup(); await s.manager.initialize(); @@ -268,6 +334,47 @@ void main() { expect((await s.api().resume()).willCancel, isFalse); }); + test('drops a write that lands after an org switch', () async { + final s = _setup(); + await s.manager.initialize(); + await s.api().load(); + final gate = Completer(); + s.routes['PATCH /v1/billing/subscription'] = (_) async { + await gate.future; + return _json({'subscription': subJson({'plan': planJson('team')})}); + }; + final writing = s.api().changePlan('team'); + s.routes['POST /v1/me/switch-org'] = (_) => _json({'session_id': 's', 'org_id': 'org_2'}); + s.routes['GET /v1/billing/subscription'] = (_) => _json({'subscription': null, 'can_manage': true}); + await s.manager.switchOrg('org_2'); + // Let the new org's load finish first, so only the stale write is left. + await Future.delayed(const Duration(milliseconds: 10)); + expect(s.api().snapshot.subscription.status, ResourceStatus.ready); + gate.complete(); + final result = await writing; + await Future.delayed(Duration.zero); + // The caller still hears what happened to the org it wrote to. + expect((result as SubscriptionChanged).subscription.plan.slug, 'team'); + expect(s.api().snapshot.subscription.data, isNull); + }); + + test('drops a write that lands after sign-out', () async { + final s = _setup(); + await s.manager.initialize(); + await s.api().load(); + final gate = Completer(); + s.routes['POST /v1/billing/subscription/resume'] = (_) async { + await gate.future; + return _json({'subscription': subJson()}); + }; + final writing = s.api().resume(); + await s.manager.signOut(); + gate.complete(); + await writing; + expect(s.api().snapshot.subscription.status, ResourceStatus.idle); + expect(s.calls.where((c) => c == 'GET /v1/billing/entitlements'), hasLength(1)); + }); + test('emits a snapshot on every change', () async { final s = _setup(); await s.manager.initialize(); diff --git a/flutter/packages/authsome_subscription_flutter/README.md b/flutter/packages/authsome_subscription_flutter/README.md index ccf09e01..473f49c3 100644 --- a/flutter/packages/authsome_subscription_flutter/README.md +++ b/flutter/packages/authsome_subscription_flutter/README.md @@ -41,6 +41,8 @@ const UsageMeter(feature: 'api_calls', label: 'API calls this month'); ManageBillingButton(onOpen: (uri) => launchUrl(uri)); ``` +`PricingTable` and `CurrentPlanCard` show plain words when a change fails, such as "Couldn't change your plan. Try again.", and pass the server's error to `onError` if you want to log it. Leave out `onCheckout` and a paid plan can't be bought: the table says so and hands `onError` a `StateError`. + `EntitlementGate` only shows its child when the plan includes the feature. While the check loads, on an error, or when the server doesn't have the plugin, you get the fallback. 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. diff --git a/flutter/packages/authsome_subscription_flutter/lib/src/subscription_widgets.dart b/flutter/packages/authsome_subscription_flutter/lib/src/subscription_widgets.dart index a0513863..b9b69ae9 100644 --- a/flutter/packages/authsome_subscription_flutter/lib/src/subscription_widgets.dart +++ b/flutter/packages/authsome_subscription_flutter/lib/src/subscription_widgets.dart @@ -33,6 +33,7 @@ class _SubscriptionBuilderState extends State { final auth = AuthProvider.of(context); final api = auth.plugin(); _api = api; + // AuthProvider is inherited, so this runs again when the user signs in. if (!auth.isAuthenticated) return; if (api.snapshot.subscription.status == ResourceStatus.idle) unawaited(api.load()); if (widget.loadPlans && api.snapshot.plans.status == ResourceStatus.idle) { @@ -51,8 +52,11 @@ class _SubscriptionBuilderState extends State { } } -bool _isLoading(Resource r) => - r.status == ResourceStatus.loading || r.status == ResourceStatus.idle; +// Idle counts as loading only while signed in: SubscriptionBuilder is +// about to start the load. Signed out, nothing will, so idle means empty. +bool _isLoading(BuildContext context, Resource r) => + r.status == ResourceStatus.loading || + (r.status == ResourceStatus.idle && AuthProvider.of(context).isAuthenticated); /// Shows [child] only when the plan includes [feature]. Loading, an error, /// or billing not being installed all show [fallback] (or [loading] while @@ -79,7 +83,7 @@ class EntitlementGate extends StatelessWidget { entitlements.data[feature]?.allowed == true) { return child; } - if (_isLoading(entitlements)) return loading ?? fallback; + if (_isLoading(context, entitlements)) return loading ?? fallback; return fallback; }); } @@ -88,8 +92,9 @@ class EntitlementGate extends StatelessWidget { String _date(BuildContext context, DateTime date) => MaterialLocalizations.of(context).formatShortDate(date.toLocal()); -void _showError(BuildContext context, Object error) { - final message = error is AuthClientException ? error.message : 'Something went wrong'; +// Server messages are written for developers, so people see plain words +// and the error itself goes to the widget's onError. +void _showError(BuildContext context, String message) { ScaffoldMessenger.maybeOf(context)?.showSnackBar(SnackBar(content: Text(message))); } @@ -105,12 +110,20 @@ class PricingTable extends StatefulWidget { this.highlight, this.returnUrl, this.cancelUrl, + this.onError, super.key, }); final ValueChanged? onSelected; + + /// Gets the payment page URL when a plan needs paying for. Without it the + /// table can't take payment: it tells the user and passes a [StateError] + /// to [onError]. final ValueChanged? onCheckout; + /// Called with the error when a change fails; the table shows plain words. + final ValueChanged? onError; + /// Plan slug to draw attention to. final String? highlight; final String? returnUrl; @@ -139,11 +152,14 @@ class _PricingTableState extends State { if (onCheckout != null) { onCheckout(result.url); } else { - _showError(context, StateError('Set PricingTable.onCheckout to open the payment page')); + debugPrint('authsome: PricingTable got a payment page but has no onCheckout to open it'); + _showError(context, "Couldn't open the payment page."); + widget.onError?.call(StateError('Set PricingTable.onCheckout to open the payment page')); } } } catch (error) { - if (mounted) _showError(context, error); + if (mounted) _showError(context, "Couldn't change your plan. Try again."); + widget.onError?.call(error); } finally { if (mounted) setState(() => _busy = false); } @@ -155,7 +171,7 @@ class _PricingTableState extends State { return SubscriptionBuilder( loadPlans: true, builder: (context, snap) { - if (_isLoading(snap.plans)) { + if (_isLoading(context, snap.plans)) { return const Center(child: Padding(padding: EdgeInsets.all(24), child: CircularProgressIndicator())); } if (snap.plans.status == ResourceStatus.error) { @@ -182,7 +198,15 @@ class _PricingTableState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(plan.name, style: theme.textTheme.titleMedium), + Row(children: [ + Expanded(child: Text(plan.name, style: theme.textTheme.titleMedium)), + if (plan.slug == widget.highlight) + Chip( + label: const Text('Recommended'), + visualDensity: VisualDensity.compact, + backgroundColor: theme.colorScheme.primaryContainer, + ), + ]), const SizedBox(height: 8), Text(plan.price?.format() ?? 'No charge', style: theme.textTheme.headlineSmall), if (plan.price != null && plan.price!.interval.isNotEmpty) @@ -228,7 +252,10 @@ class _PricingTableState extends State { /// The tenant's plan: name, price, when it renews or ends, seats, and /// cancel or keep for someone who can manage billing. class CurrentPlanCard extends StatefulWidget { - const CurrentPlanCard({super.key}); + const CurrentPlanCard({this.onError, super.key}); + + /// Called with the error when cancel or keep fails; the card shows plain words. + final ValueChanged? onError; @override State createState() => _CurrentPlanCardState(); @@ -242,7 +269,8 @@ class _CurrentPlanCardState extends State { try { await write(); } catch (error) { - if (mounted) _showError(context, error); + if (mounted) _showError(context, "Couldn't update your plan. Try again."); + widget.onError?.call(error); } finally { if (mounted) setState(() => _busy = false); } @@ -260,7 +288,7 @@ class _CurrentPlanCardState extends State { Widget build(BuildContext context) { final theme = Theme.of(context); return SubscriptionBuilder(builder: (context, snap) { - if (_isLoading(snap.subscription)) { + if (_isLoading(context, snap.subscription)) { return const Card(child: Padding(padding: EdgeInsets.all(24), child: LinearProgressIndicator())); } if (snap.subscription.status == ResourceStatus.error) { @@ -335,7 +363,7 @@ class UsageMeter extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); return SubscriptionBuilder(builder: (context, snap) { - if (_isLoading(snap.entitlements)) return const LinearProgressIndicator(); + if (_isLoading(context, snap.entitlements)) return const LinearProgressIndicator(); final e = snap.entitlements.data[feature]; if (e == null) return const SizedBox.shrink(); final unlimited = e.limit < 0; diff --git a/flutter/packages/authsome_subscription_flutter/test/widgets_test.dart b/flutter/packages/authsome_subscription_flutter/test/widgets_test.dart index 05c79c9c..e7a86770 100644 --- a/flutter/packages/authsome_subscription_flutter/test/widgets_test.dart +++ b/flutter/packages/authsome_subscription_flutter/test/widgets_test.dart @@ -69,13 +69,19 @@ class _Harness { final routes = {}; final calls = []; - Future signedIn() async { + Future signedIn() => _auth(withSession: true); + + Future signedOut() => _auth(withSession: false); + + Future _auth({required bool withSession}) async { final storage = MemoryTokenStorage(); - await storage.setItem('authsome:session', jsonEncode({ + if (withSession) { + await storage.setItem('authsome:session', jsonEncode({ 'session_token': 'tok', 'refresh_token': 'ref', - 'expires_at': DateTime.now().add(const Duration(hours: 1)).toUtc().toIso8601String(), - })); + 'expires_at': DateTime.now().add(const Duration(hours: 1)).toUtc().toIso8601String(), + })); + } final auth = AuthNotifier(AuthConfig( baseUrl: 'https://api.test', storage: storage, @@ -91,8 +97,8 @@ class _Harness { return auth; } - Future pump(WidgetTester tester, Widget child) async { - final auth = await tester.runAsync(signedIn); + Future pump(WidgetTester tester, Widget child, {bool signedOut = false}) async { + final auth = await tester.runAsync(signedOut ? this.signedOut : signedIn); await tester.pumpWidget(AuthProvider.test( notifier: auth!, child: MaterialApp(home: Scaffold(body: SingleChildScrollView(child: child))), @@ -164,6 +170,55 @@ void main() { expect(opened, Uri.parse('https://pay.example/1')); }); + testWidgets('PricingTable shows plain words, not the server message, when a change fails', (tester) async { + final h = _Harness(); + h.routes['PATCH /v1/billing/subscription'] = + (_) => _json({'error': 'already subscribed: use PATCH to change plan'}, 409); + Object? reported; + await h.pump(tester, PricingTable(onError: (e) => reported = e)); + await tester.tap(find.text('Choose Team')); + await _settle(tester); + expect(find.text("Couldn't change your plan. Try again."), findsOneWidget); + expect(find.textContaining('already subscribed'), findsNothing); + expect(reported, isA()); + }); + + testWidgets('PricingTable reports a checkout it has nowhere to send', (tester) async { + final h = _Harness(); + h.routes['GET /v1/billing/subscription'] = (_) => _json({'subscription': null, 'can_manage': true}); + h.routes['POST /v1/billing/subscription'] = (_) => _json({'checkout_url': 'https://pay.example/1'}); + Object? reported; + await h.pump(tester, PricingTable(onError: (e) => reported = e)); + await tester.tap(find.text('Choose Pro')); + await _settle(tester); + expect(find.text("Couldn't open the payment page."), findsOneWidget); + expect(reported, isA()); + expect('$reported', contains('onCheckout')); + }); + + testWidgets('PricingTable says which plan is highlighted, not only with colour', (tester) async { + await _Harness().pump(tester, const PricingTable(highlight: 'team')); + expect(find.text('Recommended'), findsOneWidget); + }); + + testWidgets('widgets stop loading when nobody is signed in', (tester) async { + await _Harness().pump( + tester, + const Column(children: [ + EntitlementGate(feature: 'sso', fallback: Text('locked'), loading: Text('checking'), child: Text('open')), + CurrentPlanCard(), + PricingTable(), + UsageMeter(feature: 'sso'), + ]), + signedOut: true, + ); + expect(find.text('locked'), findsOneWidget); + expect(find.text('checking'), findsNothing); + expect(find.byType(CircularProgressIndicator), findsNothing); + expect(find.byType(LinearProgressIndicator), findsNothing); + expect(find.text('No plan yet'), findsOneWidget); + }); + testWidgets('CurrentPlanCard says when a canceled plan ends and keeps it', (tester) async { final h = _Harness(); h.routes['GET /v1/billing/subscription'] = (_) => _json({ @@ -183,6 +238,17 @@ void main() { expect(find.textContaining('Renews on'), findsOneWidget); }); + testWidgets('CurrentPlanCard shows plain words when cancel fails', (tester) async { + final h = _Harness(); + h.routes['POST /v1/billing/subscription/cancel'] = (_) => _json({'error': 'ledger: row locked'}, 500); + Object? reported; + await h.pump(tester, CurrentPlanCard(onError: (e) => reported = e)); + await tester.tap(find.text('Cancel plan')); + await _settle(tester); + expect(find.text("Couldn't update your plan. Try again."), findsOneWidget); + expect(reported, isNotNull); + }); + testWidgets('CurrentPlanCard shows seats and no buttons to a member', (tester) async { final h = _Harness(); h.routes['GET /v1/billing/subscription'] = (_) => _json({ From 5c243e6b44ed94c24e22f69ac81682aab3d9af67 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Tue, 10 Nov 2026 10:42:39 -0600 Subject: [PATCH 11/12] 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. --- .../lib/src/generated/api_client.dart | 42 +++++++++---------- sdkgen/Makefile | 11 +++-- sdkgen/dart/deprecate_test.go | 8 +++- sdkgen/openapi/deprecate.go | 40 ++++++++++-------- sdkgen/openapi/deprecate_test.go | 24 +++++++++++ sdkgen/typescript/deprecate_test.go | 8 +++- ui/packages/core/src/generated/api-client.ts | 42 +++++++++---------- 7 files changed, 107 insertions(+), 68 deletions(-) create mode 100644 sdkgen/openapi/deprecate_test.go 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 033a4276..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,7 +1081,7 @@ class AuthClient { /// List coupons /// GET /v1/billing/coupons - @Deprecated('Use the authsome_subscription package instead.') + @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 @@ -1102,7 +1102,7 @@ class AuthClient { /// Create coupon /// POST /v1/billing/coupons - @Deprecated('Use the authsome_subscription package instead.') + @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( @@ -1116,7 +1116,7 @@ class AuthClient { /// Delete coupon /// DELETE /v1/billing/coupons/{couponId} - @Deprecated('Use the authsome_subscription package instead.') + @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( @@ -1149,7 +1149,7 @@ class AuthClient { /// Check feature entitlement /// GET /v1/billing/entitlements/{featureKey} - @Deprecated('Use the authsome_subscription package instead.') + @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 @@ -1171,7 +1171,7 @@ class AuthClient { /// List invoices /// GET /v1/billing/invoices - @Deprecated('Use the authsome_subscription package instead.') + @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 @@ -1193,7 +1193,7 @@ class AuthClient { /// Get invoice /// GET /v1/billing/invoices/{invoiceId} - @Deprecated('Use the authsome_subscription package instead.') + @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( @@ -1206,7 +1206,7 @@ class AuthClient { /// Mark invoice as paid /// POST /v1/billing/invoices/{invoiceId}/pay - @Deprecated('Use the authsome_subscription package instead.') + @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( @@ -1219,7 +1219,7 @@ class AuthClient { /// Void an invoice /// POST /v1/billing/invoices/{invoiceId}/void - @Deprecated('Use the authsome_subscription package instead.') + @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( @@ -1232,7 +1232,7 @@ class AuthClient { /// List billing plans /// GET /v1/billing/plans - @Deprecated('Use the authsome_subscription package instead.') + @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 @@ -1255,7 +1255,7 @@ class AuthClient { /// Create billing plan /// POST /v1/billing/plans - @Deprecated('Use the authsome_subscription package instead.') + @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( @@ -1269,7 +1269,7 @@ class AuthClient { /// Get billing plan /// GET /v1/billing/plans/{planId} - @Deprecated('Use the authsome_subscription package instead.') + @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( @@ -1282,7 +1282,7 @@ class AuthClient { /// Activate a billing plan /// POST /v1/billing/plans/{planId}/activate - @Deprecated('Use the authsome_subscription package instead.') + @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( @@ -1294,7 +1294,7 @@ class AuthClient { /// Archive a billing plan /// POST /v1/billing/plans/{planId}/archive - @Deprecated('Use the authsome_subscription package instead.') + @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( @@ -1397,7 +1397,7 @@ class AuthClient { /// List subscriptions /// GET /v1/billing/subscriptions - @Deprecated('Use the authsome_subscription package instead.') + @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 @@ -1420,7 +1420,7 @@ class AuthClient { /// Create subscription /// POST /v1/billing/subscriptions - @Deprecated('Use the authsome_subscription package instead.') + @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( @@ -1434,7 +1434,7 @@ class AuthClient { /// Get active subscription /// GET /v1/billing/subscriptions/active - @Deprecated('Use the authsome_subscription package instead.') + @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 @@ -1456,7 +1456,7 @@ class AuthClient { /// Cancel subscription /// POST /v1/billing/subscriptions/{subId}/cancel - @Deprecated('Use the authsome_subscription package instead.') + @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( @@ -1469,7 +1469,7 @@ class AuthClient { /// Change subscription plan /// POST /v1/billing/subscriptions/{subId}/change-plan - @Deprecated('Use the authsome_subscription package instead.') + @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( @@ -1482,7 +1482,7 @@ class AuthClient { /// Pause subscription /// POST /v1/billing/subscriptions/{subId}/pause - @Deprecated('Use the authsome_subscription package instead.') + @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( @@ -1494,7 +1494,7 @@ class AuthClient { /// Resume subscription /// POST /v1/billing/subscriptions/{subId}/resume - @Deprecated('Use the authsome_subscription package instead.') + @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( @@ -1506,7 +1506,7 @@ class AuthClient { /// Get usage summary /// GET /v1/billing/usage - @Deprecated('Use the authsome_subscription package instead.') + @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/sdkgen/Makefile b/sdkgen/Makefile index 26696e05..b868c478 100644 --- a/sdkgen/Makefile +++ b/sdkgen/Makefile @@ -28,10 +28,13 @@ UI_CORE_OVERRIDES = refreshTokens=refresh,deleteAccount=deleteMe,exportUserData= FLUTTER_CORE_OVERRIDES = refreshTokens=refresh,deleteAccount=deleteMe,exportUserData=exportMyData,verifyMFARecovery=verifyRecoveryCode # The embedded core clients point billing callers at the subscription plugin -# packages, which own those routes now. The standalone SDKs keep them as is: -# operators call the billing routes from servers. -UI_CORE_DEPRECATIONS = Billing=Use @authsome/plugin-subscription instead. -FLUTTER_CORE_DEPRECATIONS = Billing=Use the authsome_subscription package instead. +# packages, which own the tenant routes (tagged "Billing") now. The operator +# routes ("Billing Plans", "Billing Usage", ...) belong on a server, so the +# longer "Billing " rule says that instead. The standalone SDKs keep all of +# them undeprecated. Notes can't contain a semicolon, and the Dart ones no +# quotes, since they land in a quoted annotation. +UI_CORE_DEPRECATIONS = Billing=Use @authsome/plugin-subscription instead.;Billing =Operator route. Call it from your server with @authsome/client, or use @authsome/plugin-subscription for tenant billing. +FLUTTER_CORE_DEPRECATIONS = Billing=Use the authsome_subscription package instead.;Billing =Operator route. Call it from your server with the authsome Dart SDK, or use authsome_subscription for tenant billing. SDKGEN_CMD = go run ./cmd SPECGEN_CMD = go run ./cmd/specgen diff --git a/sdkgen/dart/deprecate_test.go b/sdkgen/dart/deprecate_test.go index 555c7e2b..cd328214 100644 --- a/sdkgen/dart/deprecate_test.go +++ b/sdkgen/dart/deprecate_test.go @@ -19,7 +19,10 @@ func TestGenerate_DeprecatesTaggedOperations(t *testing.T) { gen := dart.NewGenerator(dart.GeneratorConfig{ OutputMode: "embedded", - DeprecatedTags: map[string]string{"Billing": "Use the authsome_subscription package instead."}, + DeprecatedTags: map[string]string{ + "Billing": "Use the authsome_subscription package instead.", + "Billing ": "Operator route. Call it from your server.", + }, }) files, err := gen.Generate(spec) require.NoError(t, err) @@ -38,6 +41,7 @@ func TestGenerate_DeprecatesTaggedOperations(t *testing.T) { start := strings.LastIndex(client[:i], "\n\n") return client[start:i] } - assert.Contains(t, before("getActiveSubscription"), "@Deprecated('Use the authsome_subscription package instead.')") + assert.Contains(t, before("getCurrentSubscription"), "@Deprecated('Use the authsome_subscription package instead.')") + assert.Contains(t, before("getActiveSubscription"), "@Deprecated('Operator route. Call it from your server.')") assert.NotContains(t, before("getMe"), "@Deprecated") } diff --git a/sdkgen/openapi/deprecate.go b/sdkgen/openapi/deprecate.go index 2d0a6d72..67d6d503 100644 --- a/sdkgen/openapi/deprecate.go +++ b/sdkgen/openapi/deprecate.go @@ -1,29 +1,33 @@ package openapi -import ( - "slices" - "strings" -) +import "strings" -// DeprecationFor returns the note of the first rule whose tag prefix starts -// one of tags, or "" when none does. Rules map a tag prefix (e.g. "Billing") -// to the note shown to callers. Prefixes are tried in sorted order so the -// output is stable. +// DeprecationFor returns the note of the rule whose tag prefix starts one of +// tags, or "" when none does. Rules map a tag prefix (e.g. "Billing") to the +// note shown to callers. When several match, the longest prefix wins, so +// "Billing " (the operator groups such as "Billing Plans") can carry a +// different note from "Billing"; ties go to the alphabetically first prefix +// so the output is stable. func DeprecationFor(tags []string, rules map[string]string) string { - if len(rules) == 0 { - return "" - } - prefixes := make([]string, 0, len(rules)) + best := "" + found := false for p := range rules { - prefixes = append(prefixes, p) - } - slices.Sort(prefixes) - for _, p := range prefixes { + matches := false for _, tag := range tags { if strings.HasPrefix(tag, p) { - return rules[p] + matches = true + break } } + if !matches { + continue + } + if !found || len(p) > len(best) || (len(p) == len(best) && p < best) { + best, found = p, true + } + } + if !found { + return "" } - return "" + return rules[best] } diff --git a/sdkgen/openapi/deprecate_test.go b/sdkgen/openapi/deprecate_test.go new file mode 100644 index 00000000..ddd52ff5 --- /dev/null +++ b/sdkgen/openapi/deprecate_test.go @@ -0,0 +1,24 @@ +package openapi_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/xraph/authsome/sdkgen/openapi" +) + +// The tenant routes are tagged "Billing" and the operator ones "Billing +// Plans", "Billing Usage" and so on, so the longer "Billing " rule has to win +// over "Billing" whatever order the map gives them in. +func TestDeprecationFor_LongestPrefixWins(t *testing.T) { + rules := map[string]string{ + "Billing": "tenant", + "Billing ": "operator", + } + assert.Equal(t, "tenant", openapi.DeprecationFor([]string{"Billing"}, rules)) + assert.Equal(t, "operator", openapi.DeprecationFor([]string{"Billing Plans"}, rules)) + assert.Equal(t, "operator", openapi.DeprecationFor([]string{"Auth", "Billing Usage"}, rules)) + assert.Empty(t, openapi.DeprecationFor([]string{"Auth"}, rules)) + assert.Empty(t, openapi.DeprecationFor([]string{"Billing"}, nil)) +} diff --git a/sdkgen/typescript/deprecate_test.go b/sdkgen/typescript/deprecate_test.go index 21bf6cd1..926a5d76 100644 --- a/sdkgen/typescript/deprecate_test.go +++ b/sdkgen/typescript/deprecate_test.go @@ -19,7 +19,10 @@ func TestGenerate_DeprecatesTaggedOperations(t *testing.T) { gen := typescript.NewGenerator(typescript.GeneratorConfig{ OutputMode: "embedded", - DeprecatedTags: map[string]string{"Billing": "Use @authsome/plugin-subscription instead."}, + DeprecatedTags: map[string]string{ + "Billing": "Use @authsome/plugin-subscription instead.", + "Billing ": "Operator route. Call it from your server with @authsome/client.", + }, }) files, err := gen.Generate(spec) require.NoError(t, err) @@ -38,7 +41,8 @@ func TestGenerate_DeprecatesTaggedOperations(t *testing.T) { start := strings.LastIndex(client[:i], "/**") return client[start:i] } - assert.Contains(t, docFor("getActiveSubscription"), "@deprecated Use @authsome/plugin-subscription instead.") + assert.Contains(t, docFor("getCurrentSubscription"), "@deprecated Use @authsome/plugin-subscription instead.") + assert.Contains(t, docFor("getActiveSubscription"), "@deprecated Operator route. Call it from your server with @authsome/client.") assert.Contains(t, docFor("subscribe"), "@deprecated") assert.NotContains(t, docFor("getMe"), "@deprecated") } diff --git a/ui/packages/core/src/generated/api-client.ts b/ui/packages/core/src/generated/api-client.ts index 1b62eab9..d08c6659 100644 --- a/ui/packages/core/src/generated/api-client.ts +++ b/ui/packages/core/src/generated/api-client.ts @@ -1456,7 +1456,7 @@ export class AuthClient { /** * List coupons * GET /v1/billing/coupons - * @deprecated Use @authsome/plugin-subscription instead. + * @deprecated Operator route. Call it from your server with @authsome/client, or use @authsome/plugin-subscription for tenant billing. */ async listCoupons(app_id: string, token: string): Promise { const params = new URLSearchParams(); @@ -1474,7 +1474,7 @@ export class AuthClient { /** * Create coupon * POST /v1/billing/coupons - * @deprecated Use @authsome/plugin-subscription instead. + * @deprecated Operator route. Call it from your server with @authsome/client, or use @authsome/plugin-subscription for tenant billing. */ async createCoupon(body: CreateCouponRequest, token: string): Promise { const path = "/v1/billing/coupons"; @@ -1489,7 +1489,7 @@ export class AuthClient { /** * Delete coupon * DELETE /v1/billing/coupons/{couponId} - * @deprecated Use @authsome/plugin-subscription instead. + * @deprecated Operator route. Call it from your server with @authsome/client, or use @authsome/plugin-subscription for tenant billing. */ async deleteCoupon(couponId: string, token: string): Promise { const path = `/v1/billing/coupons/${couponId}`; @@ -1522,7 +1522,7 @@ export class AuthClient { /** * Check feature entitlement * GET /v1/billing/entitlements/{featureKey} - * @deprecated Use @authsome/plugin-subscription instead. + * @deprecated Operator route. Call it from your server with @authsome/client, or use @authsome/plugin-subscription for tenant billing. */ async checkEntitlement(featureKey: string, tenant_id: string, token: string, app_id?: string): Promise { const params = new URLSearchParams(); @@ -1541,7 +1541,7 @@ export class AuthClient { /** * List invoices * GET /v1/billing/invoices - * @deprecated Use @authsome/plugin-subscription instead. + * @deprecated Operator route. Call it from your server with @authsome/client, or use @authsome/plugin-subscription for tenant billing. */ async listInvoices(app_id: string, tenant_id: string, token: string): Promise { const params = new URLSearchParams(); @@ -1560,7 +1560,7 @@ export class AuthClient { /** * Get invoice * GET /v1/billing/invoices/{invoiceId} - * @deprecated Use @authsome/plugin-subscription instead. + * @deprecated Operator route. Call it from your server with @authsome/client, or use @authsome/plugin-subscription for tenant billing. */ async getInvoice(invoiceId: string, token: string): Promise { const path = `/v1/billing/invoices/${invoiceId}`; @@ -1575,7 +1575,7 @@ export class AuthClient { /** * Mark invoice as paid * POST /v1/billing/invoices/{invoiceId}/pay - * @deprecated Use @authsome/plugin-subscription instead. + * @deprecated Operator route. Call it from your server with @authsome/client, or use @authsome/plugin-subscription for tenant billing. */ async markInvoicePaid(invoiceId: string, body: MarkInvoicePaidRequest, token: string): Promise { const path = `/v1/billing/invoices/${invoiceId}/pay`; @@ -1590,7 +1590,7 @@ export class AuthClient { /** * Void an invoice * POST /v1/billing/invoices/{invoiceId}/void - * @deprecated Use @authsome/plugin-subscription instead. + * @deprecated Operator route. Call it from your server with @authsome/client, or use @authsome/plugin-subscription for tenant billing. */ async voidInvoice(invoiceId: string, body: VoidInvoiceRequest, token: string): Promise { const path = `/v1/billing/invoices/${invoiceId}/void`; @@ -1605,7 +1605,7 @@ export class AuthClient { /** * List billing plans * GET /v1/billing/plans - * @deprecated Use @authsome/plugin-subscription instead. + * @deprecated Operator route. Call it from your server with @authsome/client, or use @authsome/plugin-subscription for tenant billing. */ async listBillingPlans(app_id: string, token: string, status?: string, include_addons?: boolean): Promise { const params = new URLSearchParams(); @@ -1625,7 +1625,7 @@ export class AuthClient { /** * Create billing plan * POST /v1/billing/plans - * @deprecated Use @authsome/plugin-subscription instead. + * @deprecated Operator route. Call it from your server with @authsome/client, or use @authsome/plugin-subscription for tenant billing. */ async createBillingPlan(body: CreateBillingPlanRequest, token: string): Promise { const path = "/v1/billing/plans"; @@ -1640,7 +1640,7 @@ export class AuthClient { /** * Get billing plan * GET /v1/billing/plans/{planId} - * @deprecated Use @authsome/plugin-subscription instead. + * @deprecated Operator route. Call it from your server with @authsome/client, or use @authsome/plugin-subscription for tenant billing. */ async getBillingPlan(planId: string, token: string): Promise { const path = `/v1/billing/plans/${planId}`; @@ -1655,7 +1655,7 @@ export class AuthClient { /** * Activate a billing plan * POST /v1/billing/plans/{planId}/activate - * @deprecated Use @authsome/plugin-subscription instead. + * @deprecated Operator route. Call it from your server with @authsome/client, or use @authsome/plugin-subscription for tenant billing. */ async activateBillingPlan(planId: string, token: string): Promise { const path = `/v1/billing/plans/${planId}/activate`; @@ -1670,7 +1670,7 @@ export class AuthClient { /** * Archive a billing plan * POST /v1/billing/plans/{planId}/archive - * @deprecated Use @authsome/plugin-subscription instead. + * @deprecated Operator route. Call it from your server with @authsome/client, or use @authsome/plugin-subscription for tenant billing. */ async archiveBillingPlan(planId: string, token: string): Promise { const path = `/v1/billing/plans/${planId}/archive`; @@ -1778,7 +1778,7 @@ export class AuthClient { /** * List subscriptions * GET /v1/billing/subscriptions - * @deprecated Use @authsome/plugin-subscription instead. + * @deprecated Operator route. Call it from your server with @authsome/client, or use @authsome/plugin-subscription for tenant billing. */ async listSubscriptions(app_id: string, tenant_id: string, token: string, status?: string): Promise { const params = new URLSearchParams(); @@ -1798,7 +1798,7 @@ export class AuthClient { /** * Create subscription * POST /v1/billing/subscriptions - * @deprecated Use @authsome/plugin-subscription instead. + * @deprecated Operator route. Call it from your server with @authsome/client, or use @authsome/plugin-subscription for tenant billing. */ async createSubscription(body: CreateSubscriptionRequest, token: string): Promise { const path = "/v1/billing/subscriptions"; @@ -1813,7 +1813,7 @@ export class AuthClient { /** * Get active subscription * GET /v1/billing/subscriptions/active - * @deprecated Use @authsome/plugin-subscription instead. + * @deprecated Operator route. Call it from your server with @authsome/client, or use @authsome/plugin-subscription for tenant billing. */ async getActiveSubscription(app_id: string, tenant_id: string, token: string): Promise { const params = new URLSearchParams(); @@ -1832,7 +1832,7 @@ export class AuthClient { /** * Cancel subscription * POST /v1/billing/subscriptions/{subId}/cancel - * @deprecated Use @authsome/plugin-subscription instead. + * @deprecated Operator route. Call it from your server with @authsome/client, or use @authsome/plugin-subscription for tenant billing. */ async cancelSubscription(subId: string, body: CancelSubscriptionRequest, token: string): Promise { const path = `/v1/billing/subscriptions/${subId}/cancel`; @@ -1847,7 +1847,7 @@ export class AuthClient { /** * Change subscription plan * POST /v1/billing/subscriptions/{subId}/change-plan - * @deprecated Use @authsome/plugin-subscription instead. + * @deprecated Operator route. Call it from your server with @authsome/client, or use @authsome/plugin-subscription for tenant billing. */ async changeSubscriptionPlan(subId: string, body: ChangeSubscriptionPlanRequest, token: string): Promise { const path = `/v1/billing/subscriptions/${subId}/change-plan`; @@ -1862,7 +1862,7 @@ export class AuthClient { /** * Pause subscription * POST /v1/billing/subscriptions/{subId}/pause - * @deprecated Use @authsome/plugin-subscription instead. + * @deprecated Operator route. Call it from your server with @authsome/client, or use @authsome/plugin-subscription for tenant billing. */ async pauseSubscription(subId: string, token: string): Promise { const path = `/v1/billing/subscriptions/${subId}/pause`; @@ -1877,7 +1877,7 @@ export class AuthClient { /** * Resume subscription * POST /v1/billing/subscriptions/{subId}/resume - * @deprecated Use @authsome/plugin-subscription instead. + * @deprecated Operator route. Call it from your server with @authsome/client, or use @authsome/plugin-subscription for tenant billing. */ async resumeSubscription(subId: string, token: string): Promise { const path = `/v1/billing/subscriptions/${subId}/resume`; @@ -1892,7 +1892,7 @@ export class AuthClient { /** * Get usage summary * GET /v1/billing/usage - * @deprecated Use @authsome/plugin-subscription instead. + * @deprecated Operator route. Call it from your server with @authsome/client, or use @authsome/plugin-subscription for tenant billing. */ async getUsageSummary(app_id: string, tenant_id: string, token: string): Promise { const params = new URLSearchParams(); From 03e2f0b46150287a73f49f2ecfe2a63ccbbae85c Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Tue, 10 Nov 2026 10:42:39 -0600 Subject: [PATCH 12/12] ci(release): note the manual first pub.dev publish --- .github/workflows/release.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index abdc3976..1c323f8d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -245,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]