diff --git a/.changeset/seat-based-team-pricing.md b/.changeset/seat-based-team-pricing.md new file mode 100644 index 0000000000..380f9daa6e --- /dev/null +++ b/.changeset/seat-based-team-pricing.md @@ -0,0 +1,22 @@ +--- +"@executor-js/cloud": patch +--- + +**Team pricing is per member with unlimited executions** + +The Team plan moves from $150 per organization with a 250,000-execution +allowance to $15 per member per month with unlimited executions. The +`members` feature is unarchived in `autumn.config.ts` and billed in arrears +on the seat count the app reports; Free keeps its 3-member, 100,000-execution +shape and Enterprise stays custom with seat usage tracked for visibility. + +Seat counts reconcile from a full WorkOS recount (active members only — +pending invites hold a seat for the plan gate but are not billed) after +member removal, invitation acceptance, organization creation, and on every +login callback, which also picks up joins the app never sees a mutation for +(SSO JIT provisioning, join by domain, dashboard edits). Plans that predate +seat pricing have no members balance and are skipped, so existing +subscriptions keep billing exactly as before on their current plan version. + +The plans page, billing page, and marketing pricing cards now show the +per-member price. diff --git a/apps/cloud/src/account/org-api-key-revoke.node.test.ts b/apps/cloud/src/account/org-api-key-revoke.node.test.ts index 0de1c64d22..5b96f01f74 100644 --- a/apps/cloud/src/account/org-api-key-revoke.node.test.ts +++ b/apps/cloud/src/account/org-api-key-revoke.node.test.ts @@ -111,6 +111,7 @@ const stubAutumn = Layer.succeed(AutumnService)({ ensureCustomer: () => Effect.die("revoke does not touch billing"), checkExecutionBalance: () => Effect.die("revoke does not touch billing"), trackExecution: () => Effect.void, + setMemberSeats: () => Effect.void, }); /** diff --git a/apps/cloud/src/account/workos-account-service.ts b/apps/cloud/src/account/workos-account-service.ts index 6e70a5b9a1..dc8b234b1f 100644 --- a/apps/cloud/src/account/workos-account-service.ts +++ b/apps/cloud/src/account/workos-account-service.ts @@ -14,6 +14,7 @@ import type { Session } from "../auth/middleware"; import { WorkOSClient } from "../auth/workos"; import { ORG_SELECTOR_HEADER, authorizeOrganizationSelector } from "../auth/organization"; import { AutumnService } from "../extensions/billing/service"; +import { forkReportMemberSeats } from "../extensions/billing/member-seats"; import { countSeatsUsed, getMemberLimitForPlan, @@ -82,7 +83,7 @@ export const workosAccountProvider: Layer.Layer< // call `authorizeOrganization` (yields `WorkOSClient` + `UserStoreService`) — // can be erased to `R = never`, as the neutral AccountProvider shape // requires. Provided per method below. - const ctx = yield* Effect.context(); + const ctx = yield* Effect.context(); // Unauthenticated (missing/invalid session) => AccountUnauthorized, exactly // as the old inline `requireSession` did. @@ -364,6 +365,7 @@ export const workosAccountProvider: Layer.Layer< yield* workos .deleteOrgMembership(membershipId) .pipe(Effect.catchTag("WorkOSError", toAccountError)); + yield* forkReportMemberSeats(org.id).pipe(Effect.provideContext(ctx)); return { success: true }; }), diff --git a/apps/cloud/src/auth/handlers.ts b/apps/cloud/src/auth/handlers.ts index 05bc5ec178..a6f9a9e5d3 100644 --- a/apps/cloud/src/auth/handlers.ts +++ b/apps/cloud/src/auth/handlers.ts @@ -22,6 +22,7 @@ import { env } from "cloudflare:workers"; import { WorkOSError } from "./errors"; import { WorkOSClient } from "./workos"; import { AutumnService } from "../extensions/billing/service"; +import { forkReportMemberSeats } from "../extensions/billing/member-seats"; import { captureCauseEffect } from "../observability"; import { hasPaidOrganizationSubscription, @@ -245,6 +246,15 @@ export const CloudAuthPublicHandlers = HttpApiBuilder.group( targetOrganizationId = existingActive?.organizationId ?? null; } + // Seat changes the app never sees a mutation for (invitation + // acceptance in AuthKit, SSO JIT provisioning, join by domain, + // WorkOS dashboard edits) all end in a sign-in, so every login + // reconciles the landed org's billed seat count. Forked: billing + // must not delay the login. + if (targetOrganizationId) { + yield* forkReportMemberSeats(targetOrganizationId); + } + if ( targetOrganizationId && targetOrganizationId !== result.organizationId && @@ -451,6 +461,8 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( }), ), ); + // Seed the new org's billed seat count (the creator's seat). + yield* forkReportMemberSeats(org.id); // Try to attach the new org to the current session. This can fail // (or silently return a session still scoped to the old org) when @@ -627,6 +639,11 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( s.upsertOrganization({ id: org.id, name: org.name }), ); + // The membership is active in WorkOS from this point even if + // attaching the session below fails, so reconcile the org's billed + // seat count now. + yield* forkReportMemberSeats(org.id); + // Attach the just-accepted org to the current session. Same shape // as createOrganization: refresh + verify; if we can't pin the // session in-place, clear the cookie and let the user bounce diff --git a/apps/cloud/src/extensions/billing/member-seats.ts b/apps/cloud/src/extensions/billing/member-seats.ts new file mode 100644 index 0000000000..1c523cfdb7 --- /dev/null +++ b/apps/cloud/src/extensions/billing/member-seats.ts @@ -0,0 +1,53 @@ +// --------------------------------------------------------------------------- +// Seat-count reporting — the WorkOS → Autumn reconciliation for seat billing +// --------------------------------------------------------------------------- + +import { Effect } from "effect"; + +import { WorkOSClient } from "../../auth/workos"; +import { AutumnService } from "./service"; + +/** + * Report the organization's billable seat count to Autumn: active members + * only — a pending invite occupies a seat for the plan gate but is not + * billed until the person joins. + * + * Seats change through paths the app never sees a mutation for (invitation + * acceptance in AuthKit, SSO JIT provisioning, join by domain, WorkOS + * dashboard edits), so this reconciles from a full recount rather than + * tracking deltas. It runs after in-app membership mutations AND on every + * login callback, so drift from out-of-band changes heals on the next + * sign-in. Fire-and-forget-safe: errors are logged, never surfaced. + */ +export const reportMemberSeats = ( + organizationId: string, +): Effect.Effect => + Effect.gen(function* () { + const workos = yield* WorkOSClient; + const autumn = yield* AutumnService; + const memberships = yield* workos.listOrgMembers(organizationId); + const seats = memberships.data.filter((m) => m.status === "active").length; + yield* autumn.setMemberSeats(organizationId, seats); + }).pipe( + Effect.catch((error) => + Effect.logWarning("reportMemberSeats: seat recount failed", { organizationId, error }), + ), + Effect.withSpan("billing.reportMemberSeats"), + ); + +/** + * Fork `reportMemberSeats` off the calling request, mirroring how execution + * tracking is forked: billing must never stall or fail a user-facing + * request. Only boot-scoped services are captured (WorkOS + Autumn — no + * request-scoped resources), so the forked fiber cannot outlive anything it + * depends on. + */ +export const forkReportMemberSeats = ( + organizationId: string, +): Effect.Effect => + Effect.gen(function* () { + const ctx = yield* Effect.context(); + yield* Effect.sync(() => { + Effect.runForkWith(ctx)(reportMemberSeats(organizationId)); + }); + }); diff --git a/apps/cloud/src/extensions/billing/service.ts b/apps/cloud/src/extensions/billing/service.ts index e96c8ed84d..c4759f269c 100644 --- a/apps/cloud/src/extensions/billing/service.ts +++ b/apps/cloud/src/extensions/billing/service.ts @@ -79,6 +79,16 @@ export type IAutumnService = Readonly<{ * user-facing request. */ trackExecution: (organizationId: string) => Effect.Effect; + /** + * Set the organization's billed seat count to an absolute value. Seats are + * a continuous-use feature, so callers recount from the membership source + * of truth and this SETS the usage rather than incrementing it — deltas + * would drift against seat changes the app never sees. Orgs whose plan + * version carries no `members` item (every plan predating seat pricing) + * are skipped. Fire-and-forget-safe: errors are caught and logged; the + * returned Effect never fails. + */ + setMemberSeats: (organizationId: string, seats: number) => Effect.Effect; }>; // --------------------------------------------------------------------------- @@ -97,6 +107,7 @@ const make = Effect.sync(() => { ensureCustomer: () => notConfigured, checkExecutionBalance: () => notConfigured, trackExecution: () => Effect.void, + setMemberSeats: () => Effect.void, } satisfies IAutumnService; } @@ -169,6 +180,41 @@ const make = Effect.sync(() => { ); }).pipe(Effect.withSpan("autumn.trackExecution")); + const setMemberSeats = (organizationId: string, seats: number) => + Effect.gen(function* () { + yield* Effect.annotateCurrentSpan({ + "autumn.customer.id": organizationId, + "autumn.members.seats": seats, + }); + // getOrCreate rather than get: reuses the provisioning repair, so an + // org Autumn has never seen gets its customer minted here instead of + // 404ing forever. + const customer = yield* use((c) => c.customers.getOrCreate({ customerId: organizationId })); + // Plans that predate seat pricing have no members balance to set. + // Existing subscribers stay on those versions deliberately, so this is + // a steady state, not an error. + if (customer.balances["members"] == null) { + yield* Effect.annotateCurrentSpan({ "autumn.members.skipped": true }); + return; + } + yield* use((c) => + c.balances.update({ customerId: organizationId, featureId: "members", usage: seats }), + ); + }).pipe( + Effect.catch((error) => + Effect.gen(function* () { + // Silent seat drift means wrong invoices, so failures page just + // like a lost execution track. + yield* Effect.sync(() => { + console.error("[billing] seat sync failed:", error); + }); + yield* captureCauseEffect(error); + yield* Effect.annotateCurrentSpan({ "autumn.members.failed": true }); + }), + ), + Effect.withSpan("autumn.setMemberSeats"), + ); + const checkExecutionBalance = (organizationId: string) => Effect.gen(function* () { yield* Effect.annotateCurrentSpan({ "autumn.customer.id": organizationId }); @@ -179,7 +225,13 @@ const make = Effect.sync(() => { return { allowed: check.allowed }; }).pipe(Effect.withSpan("autumn.checkExecutionBalance")); - return { use, ensureCustomer, checkExecutionBalance, trackExecution } satisfies IAutumnService; + return { + use, + ensureCustomer, + checkExecutionBalance, + trackExecution, + setMemberSeats, + } satisfies IAutumnService; }); export class AutumnService extends Context.Service()( diff --git a/apps/cloud/src/routes/app/billing.tsx b/apps/cloud/src/routes/app/billing.tsx index 06123bc16e..6d6f303874 100644 --- a/apps/cloud/src/routes/app/billing.tsx +++ b/apps/cloud/src/routes/app/billing.tsx @@ -13,7 +13,7 @@ export const Route = createFileRoute("/{-$orgSlug}/billing")({ const PLAN_TAGLINES: Record = { free: "Free for up to 3 members", - team: "$150 per organization", + team: "$15 per member per month", enterprise: "Custom enterprise agreement", }; diff --git a/apps/cloud/src/routes/app/billing_.plans.tsx b/apps/cloud/src/routes/app/billing_.plans.tsx index 51e958f7a6..9332325766 100644 --- a/apps/cloud/src/routes/app/billing_.plans.tsx +++ b/apps/cloud/src/routes/app/billing_.plans.tsx @@ -117,29 +117,32 @@ const ENTERPRISE_FEATURES = [ "Security reviews, DPA & SOC 2 on request", ]; -const PLAN_META: Record = { +// Price display is hardcoded alongside the feature copy: Team's price lives +// on its per-seat `members` item in Autumn, not the plan-level `price` the +// SDK surfaces, so there is no live field to render it from. +const PLAN_META: Record< + string, + { + tagline: string; + inherits?: string; + features: string[]; + price: { label: string; suffix?: string }; + } +> = { free: { tagline: "For small teams getting started", - features: [ - "Up to 3 members", - "100,000 included executions per month", - "$0.20 per 1,000 additional executions", - "Unlimited sources", - ], + price: { label: "$0", suffix: "USD / month" }, + features: ["Up to 3 members", "100,000 executions per month", "Unlimited sources"], }, team: { tagline: "For growing organizations", - features: [ - "Unlimited members", - "250,000 included executions per month", - "5 minute execution timeout", - "Join by team domain", - "$0.20 per 1,000 additional executions", - ], + price: { label: "$15", suffix: "USD / member / month" }, + features: ["Unlimited executions", "Verified domains & join by team domain"], }, enterprise: { tagline: "For orgs with custom needs", inherits: "Team", + price: { label: "Custom" }, features: ENTERPRISE_FEATURES, }, }; @@ -278,15 +281,10 @@ function PlansPage() {
- {plan.id === "enterprise" ? "Custom" : `$${plan.price?.amount ?? 0}`} + {meta.price.label} - {plan.id !== "enterprise" && plan.price?.interval && ( - - USD / org / {plan.price.interval} - - )} - {plan.id !== "enterprise" && !plan.price?.interval && ( - USD + {meta.price.suffix && ( + {meta.price.suffix} )}
diff --git a/apps/marketing/src/pages/index.astro b/apps/marketing/src/pages/index.astro index b57258bc7e..80e0b442a4 100644 --- a/apps/marketing/src/pages/index.astro +++ b/apps/marketing/src/pages/index.astro @@ -591,7 +591,7 @@ Source (and the place to start if something breaks): https://github.com/UsefulSo
Pricing

- Start free, pay as you run. + Start free, pay per member.

@@ -618,8 +618,7 @@ Source (and the place to start if something breaks): https://github.com/UsefulSo
    {[ "Up to 3 members", - "100,000 included executions per month", - "$0.20 per 1,000 additional executions", + "100,000 executions per month", "Unlimited integrations", ].map((f) => (
  • @@ -650,21 +649,18 @@ Source (and the place to start if something breaks): https://github.com/UsefulSo
    $150$15 - / org / month + / member / month
    Start free trial →
      {[ - "14-day free trial, then $150 / month", - "Unlimited members", - "250,000 included executions per month", - "5 minute execution timeout", - "Join by team domain", - "$0.20 per 1,000 additional executions", + "14-day free trial, then $15 / member / month", + "Unlimited executions", + "Verified domains & join by team domain", ].map((f) => (
    • identity.credentials?.email ?? identity.label; + +/** The org the bearer is scoped to — the Autumn customer id every billing call + * is made against — read from the JWT's public claims. */ +const orgIdOf = (bearer: string): string => { + const claims = JSON.parse(Buffer.from(bearer.split(".")[1] ?? "", "base64url").toString()) as { + readonly org_id?: string; + }; + if (!claims.org_id) throw new Error("orgIdOf: bearer carries no org_id claim"); + return claims.org_id; +}; + +scenario( + "Billing · the billed seat count reaches Autumn: active members only, reconciled not incremented", + { timeout: 180_000 }, + Effect.gen(function* () { + yield* Billing; + const autumn = yield* Autumn; + const target = yield* Target; + const mcp = yield* Mcp; + const { client: apiClient } = yield* Api; + + // A fresh user who owns a brand-new free org. Creating the org forks the + // first seat recount, so the creator's seat is on the meter before anyone + // is invited. + const identity = yield* target.newIdentity(); + const bearer = yield* mcp.mintBearer(emailOf(identity)); + const customerId = orgIdOf(bearer); + const client = yield* apiClient(AccountHttpApi, identity); + + const seats = yield* autumn.expectMemberSeats(customerId, 1); + expect(seats.granted, "the free plan's members item grants the advertised seats").toBe( + FREE_MEMBER_SEATS, + ); + expect(seats.unlimited, "free-plan seats are capped, not unlimited").toBe(false); + + // An outstanding invite: the plan gate charges it a seat (so the org + // cannot invite past the cap), but billing counts active members only — + // nobody pays for a person who has not joined. + yield* client.account.inviteMember({ payload: { email: "invited@example.com" } }); + const { members, seats: gateSeats } = yield* client.account.listMembers(); + expect(gateSeats?.used, "the plan gate counts the pending invite as a seat").toBe(2); + const billedWithPendingInvite = yield* autumn.memberSeats(customerId); + expect(billedWithPendingInvite?.usage, "the pending invite is not billed").toBe(1); + + // Removing the pending membership forks another recount. Its ledger entry + // is the barrier that separates "not billed yet" from "never billed": once + // the second reconciliation has landed, the balance is the recount's + // answer, not a stale read. + const pending = members.find((member) => member.status === "pending"); + expect(pending, "the invited person appears as a pending membership").toBeTruthy(); + yield* client.account.removeMember({ params: { membershipId: pending!.id } }); + + yield* autumn.ledgerFor("balances.update").pipe( + Effect.map((entries) => entries.filter((entry) => entry.customerId === customerId)), + Effect.filterOrFail( + (entries) => entries.length >= 2, + (entries) => `only ${entries.length}/2 seat reconciliations reached Autumn`, + ), + Effect.retry(Schedule.both(Schedule.spaced("500 millis"), Schedule.recurs(40))), + ); + + const reconciled = yield* autumn.memberSeats(customerId); + expect( + reconciled?.usage, + "the recount reconciles to the active seats — it never counted the invite, so it has nothing to walk back", + ).toBe(1); + + // The balance moved by reconciliation events, not blind increments: one + // adjustment for the creator's seat, and none for the no-op recount. + const events = yield* autumn.usageEvents({ customerId, featureId: "members" }); + expect( + events.map((event) => event.value), + "one +1 adjustment for the creator; the no-op recount writes no event", + ).toEqual([1]); + }), +); diff --git a/e2e/package.json b/e2e/package.json index d32bdbad8f..0ac13eaae6 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -23,7 +23,7 @@ }, "dependencies": { "@executor-js/api": "workspace:*", - "@executor-js/emulate": "^0.14.0", + "@executor-js/emulate": "^0.14.1", "@executor-js/mcporter": "^0.11.4", "@executor-js/plugin-graphql": "workspace:*", "@executor-js/plugin-mcp": "workspace:*", diff --git a/e2e/src/surfaces/autumn.ts b/e2e/src/surfaces/autumn.ts index 2b3c32b27b..05a99bafd7 100644 --- a/e2e/src/surfaces/autumn.ts +++ b/e2e/src/surfaces/autumn.ts @@ -100,6 +100,19 @@ export interface AutumnSurface { /** Read the emulator's request ledger, filtered to one operation — used to * prove a faulted `balances.check` was actually attempted (fail-open proof). */ readonly ledgerFor: (operationId: string) => Effect.Effect; + /** The customer's `members` balance as Autumn holds it — the seat count the + * org is billed on — or null when the customer's plan carries no members + * item. */ + readonly memberSeats: ( + customerId: string, + ) => Effect.Effect<{ usage: number; granted: number; unlimited: boolean } | null, unknown>; + /** Poll until the billed seat count reaches `usage`. Seat reporting is + * forked off the mutating request (like usage tracking), so arrival is + * eventually-consistent — polling IS the contract. */ + readonly expectMemberSeats: ( + customerId: string, + usage: number, + ) => Effect.Effect<{ usage: number; granted: number; unlimited: boolean }, unknown>; } export const makeAutumnSurface = (autumnUrl: string): AutumnSurface => { @@ -218,6 +231,36 @@ export const makeAutumnSurface = (autumnUrl: string): AutumnSurface => { } }); + const memberSeats = (customerId: string) => + Effect.gen(function* () { + const response = yield* Effect.promise(() => + fetch(`${autumnUrl}/v1/customers.get_or_create`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ customer_id: customerId }), + }), + ); + if (!response.ok) { + return yield* Effect.fail( + `autumn customers.get_or_create responded ${response.status}: ${yield* Effect.promise(() => response.text())}`, + ); + } + const body = (yield* Effect.promise(() => response.json())) as { + readonly balances?: Record< + string, + { readonly usage?: number; readonly granted?: number; readonly unlimited?: boolean } + >; + }; + const balance = body.balances?.["members"]; + return balance + ? { + usage: balance.usage ?? 0, + granted: balance.granted ?? 0, + unlimited: balance.unlimited === true, + } + : null; + }); + const armFault = (input: FaultInput) => Effect.gen(function* () { const response = yield* Effect.promise(() => @@ -290,6 +333,19 @@ export const makeAutumnSurface = (autumnUrl: string): AutumnSurface => { armFault, clearFaults, ledgerFor, + memberSeats, + expectMemberSeats: (customerId, usage) => + memberSeats(customerId).pipe( + Effect.filterOrFail( + (balance): balance is { usage: number; granted: number; unlimited: boolean } => + balance !== null && balance.usage === usage, + (balance) => + `members balance for ${customerId} is ${balance ? balance.usage : "absent"}, expected ${usage}`, + ), + // Same ceiling as expectUsage: the forked seat sync drains on the + // worker's waitUntil shortly after the mutating request returns. + Effect.retry(Schedule.both(Schedule.spaced("500 millis"), Schedule.recurs(40))), + ), expectUsage: (query) => usageEvents(query).pipe( Effect.filterOrFail(