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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -238,6 +245,11 @@ jobs:
# 2. pub.dev rejects automated publishing from anything but a tag push, so a
# workflow_dispatch run can only ever rehearse. dry-run is forced on for
# those, which is also what the dry_run input maps to on a tag push.
#
# Automated publishing can only be switched on for a package that already
# exists on pub.dev, so authsome_subscription and
# authsome_subscription_flutter go up by hand (`dart pub publish`) once,
# then get the tag pattern above. Until then their step here fails.
flutter-release:
name: Flutter Release
needs: [prepare]
Expand All @@ -252,5 +264,5 @@ jobs:
with:
# Ordered: authsome_flutter depends on authsome_core, and
# authsome_flutter_ui depends on authsome_flutter.
packages: '["flutter/packages/authsome_core", "flutter/packages/authsome_flutter", "flutter/packages/authsome_flutter_ui"]'
packages: '["flutter/packages/authsome_core", "flutter/packages/authsome_flutter", "flutter/packages/authsome_flutter_ui", "flutter/packages/authsome_subscription", "flutter/packages/authsome_subscription_flutter"]'
dry-run: ${{ github.event_name != 'push' || github.event.inputs.dry_run == 'true' }}
5 changes: 5 additions & 0 deletions api/me_active_org_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,9 @@ func TestGetMe_ActiveOrgID(t *testing.T) {
withOrg := *sess
withOrg.OrgID = orgID
assert.Equal(t, orgID.String(), get(middleware.WithSession(base, &withOrg))["active_org_id"])

// A JWT carries the organization in its claims and there's no session
// row; the auth middleware puts the org on the context instead.
jwtOrg := id.NewOrgID()
assert.Equal(t, jwtOrg.String(), get(middleware.WithOrgID(base, jwtOrg))["active_org_id"])
}
70 changes: 70 additions & 0 deletions docs/content/docs/plugins/subscription.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,76 @@ route a tenant's billing writes to one of them, or check for an existing subscri
webhook.
</Callout>

## 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";

<AuthProvider baseURL={url} publishableKey={pk} plugins={[subscription()]}>
<App />
</AuthProvider>

function Billing() {
const { plan, subscription, changePlan, isPending } = useSubscription();
return (
<>
<CurrentPlanCard />
<PricingTable highlight="team" returnUrl={location.href} />
</>
);
}

<Entitled feature="sso" fallback={<UpgradePrompt />}>
<SsoSettings />
</Entitled>
```

Everything reading the subscription shares one cache. A write updates it, a sign-out clears it, and switching organization reloads it. `<Entitled>` shows the fallback until it has a positive answer, so loading or an outage never opens a paid feature.

## In Flutter

Two packages: `authsome_subscription` (pure Dart: models and state) and `authsome_subscription_flutter` (widgets).

```dart
AuthProvider(
config: AuthConfig(baseUrl: url, plugins: const [SubscriptionPlugin()]),
child: const MyApp(),
);

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

final billing = context.subscription;
final result = await billing.changePlan('team');
```

The widgets hand checkout and portal URLs back to you (`onCheckout`, `onOpen`), so the packages don't depend on a URL launcher. On both platforms the components show plain words when a write fails and pass the server's error to `onError`.

`CurrentPlanCard` asks before it cancels and says when the plan ends. If the server doesn't have the subscription plugin, the client packages say so once in the console during development, and every entitlement check fails closed.

The billing methods on the core `ui-core` and `authsome_core` clients still work, and are marked deprecated in favour of these packages.

### Another tenant's billing

Support staff who hold `billing:manage` can look after a customer's billing from the same components. Pass the customer's tenant and every read and write carries it as `tenant_id`:

```tsx
<CurrentPlanCard tenantId={customer.orgId} />
const { subscription } = useSubscription({ tenantId: customer.orgId });
```

```dart
CurrentPlanCard(tenantId: customer.orgId);
final billing = context.subscriptionFor(customer.orgId);
```

Underneath that's `api.forTenant(id)`, which returns the same API with its own cache. It keeps its tenant when you switch organization and clears when you sign out. The server refuses anyone without `billing:manage`.

## Operator routes

Plans, coupons, invoices and the ID-based subscription routes (`/subscriptions/:subId/...`)
Expand Down
28 changes: 28 additions & 0 deletions flutter/packages/authsome_core/lib/src/generated/api_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1081,6 +1081,7 @@ class AuthClient {

/// List coupons
/// GET /v1/billing/coupons
@Deprecated('Operator route. Call it from your server with the authsome Dart SDK, or use authsome_subscription for tenant billing.')
Future<ListCouponsResponse> 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
Expand All @@ -1101,6 +1102,7 @@ class AuthClient {

/// Create coupon
/// POST /v1/billing/coupons
@Deprecated('Operator route. Call it from your server with the authsome Dart SDK, or use authsome_subscription for tenant billing.')
Future<CouponResponse> createCoupon({required CreateCouponRequest body, required String token}) async {
final path = '/v1/billing/coupons';
final res = await _request(
Expand All @@ -1114,6 +1116,7 @@ class AuthClient {

/// Delete coupon
/// DELETE /v1/billing/coupons/{couponId}
@Deprecated('Operator route. Call it from your server with the authsome Dart SDK, or use authsome_subscription for tenant billing.')
Future<void> deleteCoupon({required String couponId, required String token}) async {
final path = '/v1/billing/coupons/$couponId';
await _request(
Expand All @@ -1125,6 +1128,7 @@ class AuthClient {

/// List your entitlements
/// GET /v1/billing/entitlements
@Deprecated('Use the authsome_subscription package instead.')
Future<EntitlementsResponse> 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
Expand All @@ -1145,6 +1149,7 @@ class AuthClient {

/// Check feature entitlement
/// GET /v1/billing/entitlements/{featureKey}
@Deprecated('Operator route. Call it from your server with the authsome Dart SDK, or use authsome_subscription for tenant billing.')
Future<EntitlementResponse> 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
Expand All @@ -1166,6 +1171,7 @@ class AuthClient {

/// List invoices
/// GET /v1/billing/invoices
@Deprecated('Operator route. Call it from your server with the authsome Dart SDK, or use authsome_subscription for tenant billing.')
Future<ListInvoicesResponse> 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
Expand All @@ -1187,6 +1193,7 @@ class AuthClient {

/// Get invoice
/// GET /v1/billing/invoices/{invoiceId}
@Deprecated('Operator route. Call it from your server with the authsome Dart SDK, or use authsome_subscription for tenant billing.')
Future<InvoiceResponse> getInvoice({required String invoiceId, required String token}) async {
final path = '/v1/billing/invoices/$invoiceId';
final res = await _request(
Expand All @@ -1199,6 +1206,7 @@ class AuthClient {

/// Mark invoice as paid
/// POST /v1/billing/invoices/{invoiceId}/pay
@Deprecated('Operator route. Call it from your server with the authsome Dart SDK, or use authsome_subscription for tenant billing.')
Future<void> markInvoicePaid({required String invoiceId, required MarkInvoicePaidRequest body, required String token}) async {
final path = '/v1/billing/invoices/$invoiceId/pay';
await _request(
Expand All @@ -1211,6 +1219,7 @@ class AuthClient {

/// Void an invoice
/// POST /v1/billing/invoices/{invoiceId}/void
@Deprecated('Operator route. Call it from your server with the authsome Dart SDK, or use authsome_subscription for tenant billing.')
Future<void> voidInvoice({required String invoiceId, required VoidInvoiceRequest body, required String token}) async {
final path = '/v1/billing/invoices/$invoiceId/void';
await _request(
Expand All @@ -1223,6 +1232,7 @@ class AuthClient {

/// List billing plans
/// GET /v1/billing/plans
@Deprecated('Operator route. Call it from your server with the authsome Dart SDK, or use authsome_subscription for tenant billing.')
Future<ListPlansResponse> 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
Expand All @@ -1245,6 +1255,7 @@ class AuthClient {

/// Create billing plan
/// POST /v1/billing/plans
@Deprecated('Operator route. Call it from your server with the authsome Dart SDK, or use authsome_subscription for tenant billing.')
Future<PlanResponse> createBillingPlan({required CreatePlanRequest body, required String token}) async {
final path = '/v1/billing/plans';
final res = await _request(
Expand All @@ -1258,6 +1269,7 @@ class AuthClient {

/// Get billing plan
/// GET /v1/billing/plans/{planId}
@Deprecated('Operator route. Call it from your server with the authsome Dart SDK, or use authsome_subscription for tenant billing.')
Future<PlanResponse> getBillingPlan({required String planId, required String token}) async {
final path = '/v1/billing/plans/$planId';
final res = await _request(
Expand All @@ -1270,6 +1282,7 @@ class AuthClient {

/// Activate a billing plan
/// POST /v1/billing/plans/{planId}/activate
@Deprecated('Operator route. Call it from your server with the authsome Dart SDK, or use authsome_subscription for tenant billing.')
Future<void> activateBillingPlan({required String planId, required String token}) async {
final path = '/v1/billing/plans/$planId/activate';
await _request(
Expand All @@ -1281,6 +1294,7 @@ class AuthClient {

/// Archive a billing plan
/// POST /v1/billing/plans/{planId}/archive
@Deprecated('Operator route. Call it from your server with the authsome Dart SDK, or use authsome_subscription for tenant billing.')
Future<void> archiveBillingPlan({required String planId, required String token}) async {
final path = '/v1/billing/plans/$planId/archive';
await _request(
Expand All @@ -1292,6 +1306,7 @@ class AuthClient {

/// Open the billing portal
/// POST /v1/billing/portal
@Deprecated('Use the authsome_subscription package instead.')
Future<PortalResponse> createBillingPortalSession({required PortalRequest body, required String token}) async {
final path = '/v1/billing/portal';
final res = await _request(
Expand All @@ -1305,6 +1320,7 @@ class AuthClient {

/// Get your subscription
/// GET /v1/billing/subscription
@Deprecated('Use the authsome_subscription package instead.')
Future<CurrentSubscriptionResponse> 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
Expand All @@ -1325,6 +1341,7 @@ class AuthClient {

/// Subscribe to a plan
/// POST /v1/billing/subscription
@Deprecated('Use the authsome_subscription package instead.')
Future<SubscriptionMutationResponse> subscribe({required SubscribeRequest body, required String token}) async {
final path = '/v1/billing/subscription';
final res = await _request(
Expand All @@ -1338,6 +1355,7 @@ class AuthClient {

/// Change plan or seats
/// PATCH /v1/billing/subscription
@Deprecated('Use the authsome_subscription package instead.')
Future<SubscriptionMutationResponse> updateSubscription({required UpdateSubscriptionRequest body, required String token}) async {
final path = '/v1/billing/subscription';
final res = await _request(
Expand All @@ -1351,6 +1369,7 @@ class AuthClient {

/// Cancel your subscription
/// POST /v1/billing/subscription/cancel
@Deprecated('Use the authsome_subscription package instead.')
Future<SubscriptionMutationResponse> cancelCurrentSubscription({required CancelCurrentRequest body, required String token}) async {
final path = '/v1/billing/subscription/cancel';
final res = await _request(
Expand All @@ -1364,6 +1383,7 @@ class AuthClient {

/// Keep your subscription
/// POST /v1/billing/subscription/resume
@Deprecated('Use the authsome_subscription package instead.')
Future<SubscriptionMutationResponse> resumeCurrentSubscription({required TenantRequest body, required String token}) async {
final path = '/v1/billing/subscription/resume';
final res = await _request(
Expand All @@ -1377,6 +1397,7 @@ class AuthClient {

/// List subscriptions
/// GET /v1/billing/subscriptions
@Deprecated('Operator route. Call it from your server with the authsome Dart SDK, or use authsome_subscription for tenant billing.')
Future<ListSubscriptionsResponse> 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
Expand All @@ -1399,6 +1420,7 @@ class AuthClient {

/// Create subscription
/// POST /v1/billing/subscriptions
@Deprecated('Operator route. Call it from your server with the authsome Dart SDK, or use authsome_subscription for tenant billing.')
Future<SubscriptionResponse> createSubscription({required CreateSubscriptionRequest body, required String token}) async {
final path = '/v1/billing/subscriptions';
final res = await _request(
Expand All @@ -1412,6 +1434,7 @@ class AuthClient {

/// Get active subscription
/// GET /v1/billing/subscriptions/active
@Deprecated('Operator route. Call it from your server with the authsome Dart SDK, or use authsome_subscription for tenant billing.')
Future<SubscriptionResponse> 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
Expand All @@ -1433,6 +1456,7 @@ class AuthClient {

/// Cancel subscription
/// POST /v1/billing/subscriptions/{subId}/cancel
@Deprecated('Operator route. Call it from your server with the authsome Dart SDK, or use authsome_subscription for tenant billing.')
Future<void> cancelSubscription({required String subId, required CancelSubscriptionRequest body, required String token}) async {
final path = '/v1/billing/subscriptions/$subId/cancel';
await _request(
Expand All @@ -1445,6 +1469,7 @@ class AuthClient {

/// Change subscription plan
/// POST /v1/billing/subscriptions/{subId}/change-plan
@Deprecated('Operator route. Call it from your server with the authsome Dart SDK, or use authsome_subscription for tenant billing.')
Future<void> changeSubscriptionPlan({required String subId, required ChangePlanRequest body, required String token}) async {
final path = '/v1/billing/subscriptions/$subId/change-plan';
await _request(
Expand All @@ -1457,6 +1482,7 @@ class AuthClient {

/// Pause subscription
/// POST /v1/billing/subscriptions/{subId}/pause
@Deprecated('Operator route. Call it from your server with the authsome Dart SDK, or use authsome_subscription for tenant billing.')
Future<void> pauseSubscription({required String subId, required String token}) async {
final path = '/v1/billing/subscriptions/$subId/pause';
await _request(
Expand All @@ -1468,6 +1494,7 @@ class AuthClient {

/// Resume subscription
/// POST /v1/billing/subscriptions/{subId}/resume
@Deprecated('Operator route. Call it from your server with the authsome Dart SDK, or use authsome_subscription for tenant billing.')
Future<void> resumeSubscription({required String subId, required String token}) async {
final path = '/v1/billing/subscriptions/$subId/resume';
await _request(
Expand All @@ -1479,6 +1506,7 @@ class AuthClient {

/// Get usage summary
/// GET /v1/billing/usage
@Deprecated('Operator route. Call it from your server with the authsome Dart SDK, or use authsome_subscription for tenant billing.')
Future<UsageSummaryResponse> 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
Expand Down
5 changes: 5 additions & 0 deletions flutter/packages/authsome_subscription/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Changelog

## 1.6.1

- First release.
21 changes: 21 additions & 0 deletions flutter/packages/authsome_subscription/LICENSE
Original file line number Diff line number Diff line change
@@ -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.
Loading
Loading