Back to Activepieces

EE Platform (Plans & Billing)

brain/wiki/platform-editions-ee/ee-platform-plans-billing.md

0.87.024.5 KB
Original Source

EE Platform (Plans & Billing)

Billing and entitlements are powered by Autumn. Each platform is an Autumn customer holding a customer-scoped API key; every instance (Cloud + self-hosted EE) calls Autumn directly for entitlement reads, credit track, and cached customer state, while anything needing the Autumn master key (enroll, checkout, cancel, seat quantity, auto-top-up, portal) is proxied through the Activepieces console (AUTUMN_CONSOLE_URL). The PlatformPlan entity is a projection cache of the customer's Autumn plan — request-path reads never hit Autumn inline. CE is unbilled (OPEN_SOURCE_PLAN, no-op provider).

Entities & services

  • PlatformPlan (one per platform) holds autumnCustomerId + autumnApiKey, licenseKey, plan, feature flags (ssoEnabled, scimEnabled, auditLogEnabled, embeddingEnabled, agentsEnabled, etc.), projected limits (activeFlowsLimit, projectsLimit, numeric billedTeamProjectsLimit, usersLimit, scheduledUsersLimit, includedCredits), and dedicatedWorkers jsonb.
  • billingProvider (platform/billing-provider.ts) is the CE/EE seam — a hooksFactory with a no-op CE default; EE/Cloud set autumnBillingProvider. Contract: listPlans, getBillingOverview, createCheckoutSession, adjustUnconsumableFeatureQuantity (seats), configureAutoTopUp, trackCredits/trackAppSumoAiUsage, ensureEnrolled, refreshEntitlements, activateLicense, isBillingEnforced, shouldBlockOnCredits, getCreditsAndAppSumoState, cancelSubscription/reactivateSubscription. Limit checks (checkUsersExceededLimit, checkActiveFlowsExceededLimit) are NOT on the contract — they are DB-projection reads with no provider I/O, called directly on platformPlanService.
  • platformPlanService.getUsage(platformId){ activeFlows, teamProjects, users, activeUsers, invitedSeats, creditsUsed, creditsRemaining, creditsNextResetAt, appSumoAiCreditsUsed, appSumoAiCreditsRemaining } — flows/projects/seats counted from the AP database, consumables from the Redis balance cache.

How it works

  • Enrollment: on platform create (or lazily on first plan read) ensureEnrolled — under a distributed lock, throttled 5 min — calls console enroll (free, keyed by owner email) or activate (license key), then stores the returned autumnCustomerId/autumnApiKey on platform_plan.
  • Entitlement projection (pull-based): getOrCreateForPlatform triggers a lazy refreshEntitlements at most every 15 min (ENTITLEMENTS_REFRESH_TTL_SECONDS); it does a scoped-key getCustomer, maps flags + granted balances into platform_plan via mapAutumnFeaturesToPlatformPlan (including scheduledUsersLimit from the scheduled base subscription), refreshes the Redis credit/billingEnforced caches, invalidates the billing overview, and auto-provisions a license key for self-serve paid customers. Mutations (checkout applied, cancel, seat change) call it eagerly.
  • AI credits (consumable): 1 credit per production run (flow-run-hooks), plus per AI step (flow-run-ai-usage-tracker) and per chat message (chat-usage-tracker), sent via Autumn track with idempotency keys (duplicate-track errors swallowed). Balance cached in Redis (1 h TTL), and which read strategy applies depends on the caller: the run/chat gate reads the caches only — two Redis reads raced against a 25 ms ceiling, never an inline Autumn call — and schedules every refresh in the background, while the billing UI read (getConsumablesUsageresolveCreditsCache) still fetches inline on a cold miss, single-flighted behind a per-platform distributed lock that re-reads the cache inside it (N concurrent misses → 1 getCustomer). A stale value (older than 180 s) is served immediately either way, with a debounced background refresh (decision 000020). Top-up is additive via native Autumn auto-top-up only (configureAutoTopUp).
  • Credit gating: flow runs fail openshouldBlockOnCredits blocks only when the plan carries the billingEnforced Autumn flag AND the cached balance is exhausted; the worker RPC submitPayloads then creates QUOTA_EXCEEDED runs instead of executing. Chat and managed-AI calls are hard-blocked via assertCreditsAndAppSumoNotExceeded (402). AppSumo credits always block when exhausted, regardless of billingEnforced. An unknown balance (cold cache, or Autumn unreachable — the fetch returns null on error) never blocks, at any of the three layers (decision 000020).
  • Seats (non-consumable): usedSeats = active users + non-expired pending invites (reservation — decision 000014). checkUsersExceededLimit runs inside a transaction holding FOR UPDATE on the platform_plan row and enforces min(usersLimit, scheduledUsersLimit) (scheduled seat cap — decision 000017); lowering the limit is guarded by assertSeatsNotBelowActiveUsers (DB-authoritative floor — decision 000013). Seat quantity changes go through adjustUnconsumableFeatureQuantity → console unconsumable-feature-quantity.
  • Purchases: /v1/platform-billing routes (/info, /plans, /checkout, /cancel, /reactivate, /portal, /activate, /unconsumable-feature-quantity, /consumable-product-topups/auto-topup, /setup-payment, /refresh, /projects-usage) POST to the console with the scoped key as Bearer; the console holds the master key.
  • License keys (self-hosted EE): POST /v1/platform-billing/activate → console activate → Autumn credentials; from then on the platform syncs entitlements like any Cloud customer. Paid self-serve customers get a key auto-provisioned (provisionLicenseKeyIfPaid).
  • Usage counts (active flows / team projects / users) are reported daily to PostHog only (billing-usage-report-service.ts) — the Autumn usage push was removed; scoped keys can't call balances.update and nothing consumed it (decision 000018).

Gotchas

  • One additive migration (1818...AddAutumnBillingColumnsToPlatformPlan) carries the whole schema change so the PR is revertible without DB surgery: adds autumnCustomerId/autumnApiKey/usersLimit/scheduledUsersLimit/includedCredits (backfilled from includedAiCredits, which stays) and adds numeric billedTeamProjectsLimit (backfilled NONE→0 / ONE→1 / UNLIMITED→NULL; the old varchar teamProjectsLimit stays untouched). Nothing is dropped, renamed, or type-converted — Stripe and legacy OpenRouter AI-credit columns stay in the DB, unused by the entity, with defaults added on the kept NOT NULL columns (includedAiCredits 0, aiCreditsAutoTopUpState 'disabled', agentsEnabled true, old teamProjectsLimit 'NONE') so both old and new code can insert rows. A follow-up PR drops the unused columns (decision 000019).
  • Never read entitlements inline from Autumn on a request path — always the platform_plan projection + Redis caches (isBillingEnforced is a plain Redis read defaulting to false, i.e. fail open).
  • Active flows are unlimited in the new plans (projected null); checkActiveFlowsExceededLimit still runs on flow enable/publish but only binds when a limit is set.
  • Initial plan by edition: CE/EE → OPEN_SOURCE_PLAN, Cloud → AUTUMN_FREE_PLAN. CE and TESTING environments skip enrollment/sync entirely.
  • The console base URL defaults to the production console and is overridable by an internal system prop (trailing slashes stripped) so our testing instance can point at the testing console. Deliberately absent from the self-hosting env-var reference: a self-hoster has no reason to change it, and the default must always be the one that works with zero setup. The Autumn SDK's own base URL is not configurable — nothing passes serverURL — so the console override cannot redirect entitlement reads.
  • Credit metering for managed AI happens post-run in centralized worker execution (decision 000016), so in-flight spend is invisible to the gate.
  • A first-time chatter's plan grant must finish before the credit gate runs — await it, never fire-and-forget. computeCreditState blocks only when enforced && exhausted, and free carries the BILLING_ENFORCED customer flag, so a free platform whose allowance is spent is blocked. chatPlanGrant.grant is what attaches the plan that gives that user credits, and activateLicense ends with refreshEntitlements, so awaiting it lets the gate 30 lines later read the new balance; backgrounding it bounces the user's very first message with QUOTA_EXCEEDED and only works on retry. Wrap the await in tryCatch — the grant's claim/plan-lookup calls sit outside its internal tryCatch and would otherwise fail the chat request. This ordering was documented in a comment that got deleted during the license-key → Autumn swap; don't re-optimize it away.
  • AutumnFeatureId (platform.model.ts) is a three-way contract: each value must equal BOTH the platform_plan column name (the projection writes them verbatim via mapAutumnFeaturesToPlatformPlan and forwards them as Autumn featureIds) AND the feature id configured in the Autumn dashboard. Renaming any one side silently breaks projection or metering for that feature. One deliberate exception: feature id teamProjectsLimit projects onto plan.billedTeamProjectsLimit (decision 000019).
  • Every path that admits a production run must go through shouldBlockRunOnCredits / assertRunCreditsNotExceeded (billing-provider.ts) — there are four entry points (sync webhook, worker submitPayloads, manual trigger, retry), and the gate was originally added to only the first two, so a zero-credit platform could replay its whole QUOTA_EXCEEDED backlog and get every run executed for free. Webhook/polling/manual-trigger admit a QUOTA_EXCEEDED run instead of running it (the builder renders the out-of-credits message); retry throws QUOTA_EXCEEDED (402) so single retry, bulkRetry, and the MCP ap_retry_run tool all refuse. Testing runs are never gated here — their AI spend is gated at the AI proxy instead.
  • AP_EDITION=ee short-circuits all four run gatesshouldBlockRunOnCredits returns false before touching the provider, so a self-hosted EE box does zero billing I/O on run admission. This is a latency stopgap, not policy, and the cost it was dodging is now much smaller: since the cache-only rewrite the gate costs two Redis reads per admission bounded at 25 ms, with no lock and no inline Autumn call on any path. What remains before the branch can go is an in-process TTL cache so an unenrolled platform costs nothing per run (decision 000020). Chat and managed-AI gates are unaffected and still run on every edition.
  • Only PersistedToolCallStatus.COMPLETED tool calls are billable (chatToolBilling.countBillableToolCallsInLatestTurn). ERROR means the call never returned a result at all, so there is nothing to charge for; a tool that ran and returned a ❌ … failure message is COMPLETED and is billed, because the third-party work happened. This count was telemetry-only before credits — treat any change to it as a pricing change.
  • Autumn's auto top-up lands after the track response returns, so trackCredits caches a balance that is already wrong. Verified against the sandbox (2026-07-29): a track that crosses the threshold returns remaining at its pre-top-up value, and the top-up appears only on a subsequent getCustomer. Since trackCredits writes response.balance verbatim with a fresh syncedAt, an exhausting run pins remaining: 0 in Redis and isCreditsStale would suppress the refetch for CREDITS_REFETCH_PERIOD_MS (180s) — a funded platform with working auto-recharge gated for three minutes. Two guards close this: scheduleCreditsCacheMaintenance refreshes whenever the cached balance is stale or would actually block (so unenforced plans sitting at zero never trigger a call), fired through rejectedPromiseHandler and debounced to one getCustomer per platform per CUSTOMER_STATE_REFRESH_DEBOUNCE_SECONDS (15s) by runOnceWithin; and /consumable-product-topups/auto-topup now calls refreshEntitlements after configureAutoTopUp, like its four sibling routes always did. Do not try to derive that debounce from the cache's own syncedAttrackCredits stamps it fresh, so "recently written" and "recently verified against Autumn" are different facts and conflating them disables the guard. The refresh is background, so the request that first sees the stale zero is still blocked and a topped-up platform keeps producing QUOTA_EXCEEDED runs for up to that debounce window; re-verifying inline is what this deliberately gave up, because it cost a RedLock acquire (retrying every 200 ms for up to its full 15s TTL when contended) plus an Autumn round-trip, on the webhook path. Related evaluation semantics, same verification: a getCustomer read does not trigger evaluation; a billing_controls mutation does (if already below threshold); one evaluation grants exactly quantity once and does not loop to clear the threshold; track with value: 0 is accepted, deducts nothing, and is not a usable way to force evaluation. Note value defaults to 1 when omitted.
  • With overage_allowed: false a balance floors at zero and never goes negative — tracking 60500 against a remaining of 54990 deducted only 54990 and silently dropped the excess (usage capped at granted). Combined with post-run bulk AI metering (decision 000016), a single expensive run against a nearly-empty balance under-bills by the overflow instead of carrying it.
  • Never write platform_plan by loading the row and spreading it into save()platformPlanService.update() and setAutumnCredentials() both did (save({ ...platformPlan, ...changes })) and it cost an activation. TypeORM save() re-SELECTs the row and diffs your object against it, so a column another request committed between your read and the save is indistinguishable from one you edited on purpose, and your stale value wins. A refreshEntitlementsupdate() (no lock, fires on any plan read) overlapping activateLicense's setAutumnCredentials (holds the enroll lock, which the refresh never takes) reverted autumnCustomerId/autumnApiKey to the pre-activation customer while keeping the new licenseKey — the platform then reads entitlements and meters credits against an orphaned free customer, and ensureEnrolled early-returns on any non-nil customer id so it never self-heals. Both now use targeted repo().update({ platformId }, changes), which cannot write a column its caller didn't name. Two traps when working on this: the window is not the slow getCustomer call (those creds only build the client and are never written back) but the ~1ms gap between update()'s own findOneByOrFail and save()'s internal reload; and because save()'s diff emits a narrow UPDATE whenever nothing raced, a test that mutates credentials before calling update() passes on the broken code too — plan-update-column-isolation.test.ts forces the interleave by spying on the shared repository's findOneByOrFail to commit the activation mid-call.
  • Credits do not all reset on the same cadence, and the cadence is not on the balance. free grants 100 credits with reset.interval = day; every paid plan is month (plus, team, ultimate, embed, appsumo) or year (custom embed/enterprise variants). Autumn's Balance carries only nextResetAt — the interval lives on the plan item, so it has to be read off the current subscription/purchase's unpriced apCredits item (toCreditsResetInterval, mirroring toPurchasablePlan) and is surfaced as creditsResetInterval on PlatformBillingInformation. Pick the unpriced item: paid plans also carry a priced one_off prepaid apCredits item for top-ups, and matching on feature id alone picks the wrong one. UI copy follows from it — daily reads "Resets in 5 hours" (a duration), monthly/annual reads "Resets on 1 Aug 2026" (a date); the card said "Resets in <absolute date>" for everyone until this was exposed. Both surfaces go through billingUtils.resolveCreditsReset (packages/web/src/features/billing/utils/billing-utils.ts) because they have different data: the billing page has the full PlatformBillingInformation, but the sidebar only has platform.usage — it fetches the subscription lazily (admin + ≥70% used + paid), so creditsResetInterval is usually absent there and the helper falls back to !isPaid (free is the only daily plan today). Don't "fix" the sidebar by enabling that query unconditionally — it would add a billing request to every page load for every user.
  • platform_plan.plan still carries pre-Autumn plan names on any platform that has not been read since the migration deployed. There was no 'free' plan before Autumn: the Cloud free tier was plan = 'standard' (STANDARD_CLOUD_PLAN), and the old PlanName enum held only STANDARD, ENTERPRISE, and APPSUMO_ACTIVEPIECES_TIER1..6. The column is rewritten to an Autumn plan id only by refreshEntitlements via mapAutumnFeaturesToPlatformPlan, which fires lazily on a plan read, so a dormant platform keeps 'standard' indefinitely. Any cohort query written as plan = 'free' therefore selects only the platforms that have been active since the deploy and silently skips the dormant ones, which are usually the exact population a grandfathering or migration pass is meant to catch. Match 'standard' as well. The console cannot supply a substitute date either: autumn_customers.created_at is when AP enrolled the platform, not when it signed up, and enrolment is lazy, so it says nothing about what plan a platform held on a given date. The whole table also only begins at the 2026-07-23 Autumn catalog go-live.
  • CONSUMABLE_AUTUMN_FEATURE_IDS (apCredits, appSumoAiCredits) is the source of truth splitting the two billing mechanics: consumables are prepaid balances the customer tops up (units added to a depleting pool); every other billable feature (e.g. seats) is a recurring per-unit quantity edited and charged each period — never "topped up".
  • A top-up only works if the plan's prepaid item is interval: one_off — Autumn fires auto top-ups (and one-click credit purchases) exclusively against a one-off prepaid purchase path; a prepaid item priced interval: month is a selectable monthly bucket (a recurring subscription quantity), so there is nothing for the top-up to buy and it silently no-ops. Verified in sandbox 2026-08-01: team's apCredits prepaid item is one_off and tops up; free_legacy and appsumo carry the appSumoAiCredits prepaid item at interval: month / reset: month, and a customer with the control enabled (threshold 170, card on file) crossed the threshold twice via balances.track with no purchase and prepaid_grant stuck at 0. reset: month on a top-up item is wrong for a second reason — it would wipe purchased credits each cycle. Nothing warns you: toBillableFeatures (autumn-billing.ts) surfaces any item with billingMethod === 'prepaid' regardless of interval, so the UI advertises a price the catalog cannot sell.
  • Fixing that interval on a $0 plan turns its customers from subscriptions into purchases — and that is fine, but the code has to expect it. Autumn classifies a plan by its prices: no paid price at all → free plan → attach creates a subscription; at least one paid price and all of them one_offone-off plan → attach creates a purchase. free_legacy and appsumo have price: null, so the monthly prepaid item was the only thing keeping them recurring; making it one_off (2026-08-01) reclassified them, and the plan migration moved the existing customer's subscriptions[0] into purchases[0]. team is immune — its $200/mo base price keeps it recurring alongside its one_off credit item. A purchase carries planId/startedAt/expiresAt/quantity and no currentPeriodStart/currentPeriodEnd/trialEndsAt/status, so anything reading customer.subscriptions silently sees an empty array: toBillableFeatures returned [], consumableFeatures emptied, and the billing page dropped its credits + AutoRechargeCard (gated on !isNil(creditsFeature)) while still naming the plan correctly, because only toBillingInfo had the purchases fallback. Both now share selectCurrentPlan. Billing-period fields deliberately still read the subscription and fall back to the calendar month — a comped lifetime plan has no billing cycle, and that fallback also becomes the credit-usage graph's range.
  • Cancelling has two UI entry points and a third path that never reaches the cancel call at all. The billing page's "Cancel subscription" link (app/routes/platform/billing/index.tsx) and the plan selector's Free-plan "Downgrade" button (plan-selector.tsx) both render the same CancelSubscriptionDialog (the churn survey, which carries planSelectorUtils.dropToFreeWarning in its warning alert) and both call cancelWithSeatCheck from useCancelSubscriptionGuard. Anything added to the cancel moment (copy, survey options, telemetry) belongs in the dialog or the hook, never in one call site, or the other entry point silently skips it. The third path is the seat floor: when active users exceed the Free plan's seats, cancelWithSeatCheck opens the deactivate-users dialog instead of cancelling, and a QUOTA_EXCEEDED from the server does the same thing after the fact, so the user can leave the flow having intended to cancel without a single request reaching /v1/platform-billing/cancel — and with the survey answers they just typed thrown away (decision 000023).
  • Every console endpoint AP calls must live under /v1. AP instances self-host and upgrade on their own schedule, so an AP-facing console route is a public contract and the version segment is the only place a breaking change can be absorbed without stranding older instances. All /api/v1/billing/* routes comply; three do not and should move when next touched: /api/external/grant-chat-plan (called from autumn-utils.ts), /api/chat-analytics/external/sync and /api/chat-analytics/external/rollout-funnel (called from ee/chat/chat-analytics-sync.ts). Console-web-only routes are not AP-facing and stay unversioned.
  • Anything that must happen on every cancellation goes in the console's /api/v1/billing/cancel controller, not in billingService.cancel. That service method early-returns when the customer's plan is nil or Free, before it touches Autumn, so a side effect placed inside it silently never runs for exactly the customers whose state is unusual. The cancellation-feedback insert sits in the controller for this reason, and is best-effort: it logs on failure and never fails the cancellation.

Key files

Entry point: platformPlanService (platform-plan.service.ts) for projection, usage, and seat checks; billingProvider.get(log) for everything billing.

  • packages/server/api/src/app/platform/billing-provider.tsBillingProvider contract, CE no-op default, assertCreditsAndAppSumoNotExceeded, trackCreditsWithAppSumo
  • packages/server/api/src/app/ee/platform/platform-plan/billing-providers/autumn-billing.ts — EE provider impl (overview, gates, credit caches)
  • packages/server/api/src/app/ee/platform/platform-plan/billing-providers/autumn-utils.ts — console client, enrollment, refreshEntitlements, mapAutumnFeaturesToPlatformPlan
  • packages/server/api/src/app/ee/platform/platform-plan/platform-plan.service.ts — lazy sync triggers, countUsedSeats, checkUsersExceededLimit, getAutumnCredentials
  • packages/server/api/src/app/ee/platform/platform-plan/platform-plan.controller.ts/v1/platform-billing routes
  • packages/server/api/src/app/ee/billing-usage-report/billing-usage-report-service.ts — daily PostHog usage snapshots
  • packages/core/shared/src/lib/ee/billing/index.ts — plan constants (AUTUMN_FREE_PLAN, OPEN_SOURCE_PLAN), checkout/top-up schemas
  • packages/web/src/features/billing/ + packages/web/src/app/routes/platform/billing/index.tsx — plans, credits, seats, license activation UI

Decisions: brain/decisions/000013-active-user-seat-floor-is-enforced-db-authoritatively.md, 000014-pending-invitations-reserve-seats.md, 000015-jit-provisioning-plans-imply-unlimited-seats.md, 000016-managed-ai-metering-moves-to-centralized-worker-execution.md, 000017-scheduled-downgrades-cap-seats-immediately.md, 000018-usage-counts-report-to-posthog-only.md, 000019-autumn-platform-plan-schema-ships-additively.md, 000020-credit-gating-fails-open-on-an-unknown-balance.md, 000021-legacy-free-platforms-are-comped-an-appsumo-clone-from-ensureenrolled.md, 000022-non-self-serve-plans-are-deliberately-non-recurring.md, 000023-cancellation-feedback-rides-the-cancel-call.md. Paths verified 2026-07-26.