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
22 changes: 22 additions & 0 deletions .changeset/seat-based-team-pricing.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions apps/cloud/src/account/org-api-key-revoke.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});

/**
Expand Down
4 changes: 3 additions & 1 deletion apps/cloud/src/account/workos-account-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<WorkOSClient | UserStoreService>();
const ctx = yield* Effect.context<WorkOSClient | UserStoreService | AutumnService>();

// Unauthenticated (missing/invalid session) => AccountUnauthorized, exactly
// as the old inline `requireSession` did.
Expand Down Expand Up @@ -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 };
}),

Expand Down
17 changes: 17 additions & 0 deletions apps/cloud/src/auth/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 &&
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
53 changes: 53 additions & 0 deletions apps/cloud/src/extensions/billing/member-seats.ts
Original file line number Diff line number Diff line change
@@ -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<void, never, WorkOSClient | AutumnService> =>
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<void, never, WorkOSClient | AutumnService> =>
Effect.gen(function* () {
const ctx = yield* Effect.context<WorkOSClient | AutumnService>();
yield* Effect.sync(() => {
Effect.runForkWith(ctx)(reportMemberSeats(organizationId));
});
});
54 changes: 53 additions & 1 deletion apps/cloud/src/extensions/billing/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,16 @@ export type IAutumnService = Readonly<{
* user-facing request.
*/
trackExecution: (organizationId: string) => Effect.Effect<void, never, never>;
/**
* 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<void, never, never>;
}>;

// ---------------------------------------------------------------------------
Expand All @@ -97,6 +107,7 @@ const make = Effect.sync(() => {
ensureCustomer: () => notConfigured,
checkExecutionBalance: () => notConfigured,
trackExecution: () => Effect.void,
setMemberSeats: () => Effect.void,
} satisfies IAutumnService;
}

Expand Down Expand Up @@ -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 });
Expand All @@ -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<AutumnService, IAutumnService>()(
Expand Down
2 changes: 1 addition & 1 deletion apps/cloud/src/routes/app/billing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export const Route = createFileRoute("/{-$orgSlug}/billing")({

const PLAN_TAGLINES: Record<string, string> = {
free: "Free for up to 3 members",
team: "$150 per organization",
team: "$15 per member per month",
enterprise: "Custom enterprise agreement",
};

Expand Down
42 changes: 20 additions & 22 deletions apps/cloud/src/routes/app/billing_.plans.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,29 +117,32 @@ const ENTERPRISE_FEATURES = [
"Security reviews, DPA & SOC 2 on request",
];

const PLAN_META: Record<string, { tagline: string; inherits?: string; features: string[] }> = {
// 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,
},
};
Expand Down Expand Up @@ -278,15 +281,10 @@ function PlansPage() {

<div className="mt-4 flex items-baseline gap-1.5">
<span className="text-2xl font-semibold text-foreground tabular-nums">
{plan.id === "enterprise" ? "Custom" : `$${plan.price?.amount ?? 0}`}
{meta.price.label}
</span>
{plan.id !== "enterprise" && plan.price?.interval && (
<span className="text-sm text-muted-foreground">
USD / org / {plan.price.interval}
</span>
)}
{plan.id !== "enterprise" && !plan.price?.interval && (
<span className="text-sm text-muted-foreground">USD</span>
{meta.price.suffix && (
<span className="text-sm text-muted-foreground">{meta.price.suffix}</span>
)}
</div>

Expand Down
18 changes: 7 additions & 11 deletions apps/marketing/src/pages/index.astro
Original file line number Diff line number Diff line change
Expand Up @@ -591,7 +591,7 @@ Source (and the place to start if something breaks): https://github.com/UsefulSo
<div class="mb-14 mx-auto text-center max-w-2xl">
<div class="eyebrow mb-4">Pricing</div>
<h2 class="section-title mx-auto max-w-[20ch]">
Start free, pay as you <span class="serif-italic">run</span>.
Start free, pay per <span class="serif-italic">member</span>.
</h2>
</div>

Expand All @@ -618,8 +618,7 @@ Source (and the place to start if something breaks): https://github.com/UsefulSo
<ul class="check-list">
{[
"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) => (
<li>
Expand Down Expand Up @@ -650,21 +649,18 @@ Source (and the place to start if something breaks): https://github.com/UsefulSo
<div class="flex items-baseline gap-1.5 mb-6">
<span
class="text-[40px] leading-none font-semibold tracking-[-0.02em] text-ink tabular-nums"
>$150</span
>$15</span
>
<span class="text-[13.5px] text-ink-3">/ org / month</span>
<span class="text-[13.5px] text-ink-3">/ member / month</span>
</div>
<a href="/cloud" class="btn-primary self-start mb-7"
>Start free trial →</a
>
<ul class="check-list">
{[
"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) => (
<li>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"
Expand Down
Loading
Loading