Feature Specification: Subscriptions & Plan Gating
Feature ID: subscriptions
Status: Retrospective
Created: 2026-05-08
Last updated: 2026-05-08
Owner: Ever Works Team
1. Overview
The subscriptions feature is the platform's plan-tiering and usage-billing
surface. It seeds three first-party plans (FREE, STANDARD, PREMIUM) on
module init, exposes a two-endpoint HTTP surface (GET /api/subscriptions/plan
and POST /api/subscriptions/plan) for reading and switching the caller's
plan, and lends three reusable resolution primitives to downstream consumers
(SubscriptionService.resolvePlanForUser, getCadenceAllowances,
requiresUsageBilling) that drive plan-aware behaviour in
WorkScheduleService (cadence gating, maxWorks quota check, pay-per-use
fallthrough). It also wires a one-call usage-ledger pipeline
(UsageLedgerService.recordUsage) that writes a UsageLedgerEntry for every
billable usage event and fans the entry out to a pluggable BillingProvider
(default ManualBillingProvider, no external charge). The whole subsystem
short-circuits when SUBSCRIPTIONS_ENABLED is not the literal string
'true': every cadence is reported as allowed-and-not-pay-per-use,
requiresUsageBilling always returns false, recordUsage returns null
without writing, and assignPlanToUser rejects with HTTP 400.
2. User Scenarios
2.1 Primary scenarios
- Given subscriptions are disabled (
SUBSCRIPTIONS_ENABLED≠'true'), when I callGET /api/subscriptions/plan, then the response is{ status: 'success', enabled: false, plan: null }and no plan resolution runs. - Given subscriptions are enabled and I have no
user.defaultPlanand no activeUserSubscription, when I callGET /api/subscriptions/plan, then the response is{ status: 'success', enabled: true, plan: { code: 'free', name: 'Free', allowedCadences: [...] } }whereallowedCadencesis the seven-cadence allowance grid resolved from the configured default plan (SUBSCRIPTIONS_DEFAULT_PLAN, falling back to'free'). - Given I have an active
UserSubscriptionwhose plan isPREMIUM, when I callGET /api/subscriptions/plan, then the response plan code is'premium', name is'Premium', and every cadence inallowedCadencesis{ allowed: true, payPerUse: false }. - Given subscriptions are enabled, when I call
POST /api/subscriptions/planwith{ planCode: 'standard' }, then myusers.defaultPlanIdis updated to the row whosecode === 'standard', the response plan reflects the new code/name/allowances, and a subsequentGET /api/subscriptions/planreflects the updated plan. - Given my schedule on a
WEEKLYcadence is allowed by theSTANDARDplan, whenWorkScheduleService.markRunCompletedfinalises a run withbillingMode = 'subscription', thenUsageLedgerService.recordUsageshort-circuits and no ledger row is written. - Given my plan is
STANDARD(which does NOT allowHOURLY) and I want hourly updates, when I update the schedule withbillingMode = 'usage'andcadence = 'hourly', thenrequiresUsageBillingreturnsfalse(the schedule is accepted), and every completed run records aUsageLedgerEntrywithtriggerType = 'scheduled',billingMode = 'usage',units = 1, andamountCents = round(PAY_PER_USE_PRICE_USD * 100)(default500). - Given subscriptions are enabled and I am on the
FREEplan (which permitsMONTHLYonly in production seeds), when I try to switch a schedule toHOURLYwithbillingMode = 'subscription', thenWorkScheduleService.updateSchedulethrowsBadRequestException({ status: 'error', message: 'Selected cadence is not available on your plan. Switch to pay-per-use to continue.' })(Note: the currentFREEseed temporarily allows ALL cadences — see §6).
2.2 Edge cases & failures
- Given subscriptions are disabled, when I call
POST /api/subscriptions/plan, then the controller throwsBadRequestException('Subscriptions are disabled')BEFORE any user lookup or plan assignment runs. - Given subscriptions are enabled, when I call
POST /api/subscriptions/planwith aplanCodethat fails theclass-validator@IsEnum(SubscriptionPlanCode)check, then the global validation pipe rejects the request with HTTP 400 BEFORE the controller body executes. - Given subscriptions are enabled and
SUBSCRIPTIONS_DEFAULT_PLANresolves to a plan that has been deleted from thesubscription_planstable, whenresolvePlanForUserruns for a user with no subscription and nodefaultPlan, then the service logsSubscription plan <code> not found, falling back to FREEat warn level and returns theFREEplan; if theFREErow is also missing, it throwsError('Default subscription plan not found'). - Given the caller's
planCodebody is upper-case (e.g.'STANDARD'), whenassignPlanToUserruns, thennormalizePlanCodelowercases it, matches againstSubscriptionPlanCodevalues, and falls back to'free'for any unknown code. - Given
assignPlanToUseris called with a code that normalises to a value not present in thesubscription_planstable, when the repository lookup misses, then the service throwsNotFoundException('Plan not found'). - Given subscriptions are enabled and
WorkScheduleService.updateScheduleis creating-or-activating a schedule, when the user's count of active schedules already equalsplan.maxWorks, then the service throwsBadRequestException({ status: 'error', code: 'PLAN_LIMIT_EXCEEDED', message: 'Your <DisplayName> plan allows up to <maxWorks> scheduled works.' })BEFORE the upsert. - Given
UsageLedgerService.recordUsageis invoked withbillingMode = 'subscription', when the service runs, then it returnsnullwithout writing a ledger row even though subscriptions are enabled. - Given the platform's
BillingProviderraises whilerecordUsageChargeis awaited, whenrecordUsageruns, then the exception propagates back to the caller — there is no built-in retry loop and no in-memory swallowing today (see §6 OQ-3). - Given the
subscription_planstable is freshly created, whenSubscriptionService.onModuleInitfires, thenseedPlansupserts all three plans bycode(rows whosecodealready exists are updated in place viaSubscriptionPlanRepository.upsert, so the seededdisplayName,maxWorks,allowedCadences,monthlyPrice,overagePricePerRun, andcurrencyALWAYS reflect the latest seed shape on every boot).
3. Functional Requirements
- FR-1 The system MUST seed three plans (
FREE/STANDARD/PREMIUM) on module init viaSubscriptionPlanRepository.upsert, keyed oncode. - FR-2 The system MUST expose
GET /api/subscriptions/planbehindAuthSessionGuardreturning{ status: 'success', enabled, plan }whereplanisnullwhen subscriptions are disabled and an{ code, name, allowedCadences }object otherwise. - FR-3 The system MUST expose
POST /api/subscriptions/planbehindAuthSessionGuardaccepting{ planCode }validated by@IsEnum(SubscriptionPlanCode), throwingBadRequestException('Subscriptions are disabled')when subscriptions are off, and otherwise updatingusers.defaultPlanIdand returning the same envelope asGET. - FR-4 The system MUST resolve a user's plan by precedence: active
UserSubscription.plan→user.defaultPlan→ configured default plan (config.subscriptions.getDefaultPlanCode()) →FREEfallback. - FR-5 The system MUST gate plan resolution on
config.subscriptions.isEnabled(): whenfalse,resolvePlanForUserreturns the configured default plan (or FREE) andgetCadenceAllowancesreturns every cadence as{ allowed: true, payPerUse: false }. - FR-6 The system MUST compute cadence allowances against the plan's
allowedCadencesset: cadences in the set are{ allowed: true, payPerUse: false }; cadences not in the set are{ allowed: false, payPerUse: true, reason: 'Upgrade to <Tier> for this cadence' }where<Tier>isPremiumfor hourly/3h/8h cadences,Standardfor 12h/daily/weekly, andFreefor monthly. - FR-7 The system MUST expose
getDefaultCadence(plan)that returns the LAST entry ofplan.allowedCadenceswhen the array is non-empty, falling back toWorkScheduleCadence.MONTHLYwhen empty. - FR-8 The system MUST expose
requiresUsageBilling(cadence, plan, billingMode)returningfalsewhen subscriptions are disabled OR the cadence is in the plan's allowed set; otherwise returningbillingMode !== 'usage'(i.e. cadence not allowed AND not in usage mode). - FR-9 The system MUST refuse
assignPlanToUserwhen subscriptions are disabled (BadRequestException('Subscriptions are disabled')) and when the resolved plan code does not exist in the table (NotFoundException('Plan not found')). - FR-10 The system MUST normalise
assignPlanToUser'splanCodeto lower-case, match againstSubscriptionPlanCodevalues, and fall back to'free'for unknown codes. - FR-11 The system MUST mutate both the persisted
users.defaultPlanIdand the in-memoryuser.defaultPlan/user.defaultPlanIdreferences afterassignPlanToUsersucceeds, so callers do not need to refetch. - FR-12 The system MUST short-circuit
UsageLedgerService.recordUsagetoreturn nullwhen subscriptions are disabled ORbillingMode !== 'usage', writing no ledger row. - FR-13 The system MUST write a
UsageLedgerEntry(units = 1,amountCents = max(0, round(PAY_PER_USE_PRICE_USD * 100))with default5USD →500cents, currency fromBillingProvider.getDefaultCurrency(), metadata{ cadence: schedule?.cadence }) on every billablerecordUsagecall, then awaitBillingProvider.recordUsageCharge(entry), then return the entry. - FR-14 The system MUST register a default
ManualBillingProviderwhoserecordUsageChargeis a no-opPromise<void>returningundefined, and whosegetDefaultCurrency()readsconfig.billing.getDefaultCurrency()(BILLING_DEFAULT_CURRENCYenv, default'usd'). - FR-15 The system MUST refuse cadence transitions in
WorkScheduleService.updateSchedulewhenrequiresUsageBilling(cadence, plan, billingMode)returnstrue, throwingBadRequestException({ status: 'error', message: 'Selected cadence is not available on your plan. Switch to pay-per-use to continue.' })BEFORE any persistence. - FR-16 The system MUST enforce
plan.maxWorksinWorkScheduleService.updateSchedulewhen subscriptions are enabled and the request creates-or-activates a schedule: ifscheduleRepository.countActiveByUser(user.id) >= plan.maxWorks, throwBadRequestException({ status: 'error', code: 'PLAN_LIMIT_EXCEEDED', message: 'Your <DisplayName> plan allows up to <maxWorks> scheduled works.' })BEFORE the upsert. - FR-17 The system MUST record a
UsageLedgerEntryfromWorkScheduleService.markRunCompletedONLY (not frommarkRunFailed/markRunSkipped) — failed and skipped runs are free. - FR-18 The system MUST include
triggerType = UsageLedgerTriggerType.SCHEDULEDon ledger rows written byWorkScheduleService.markRunCompletedand forwardgenerationHistoryIdfrom the call site to the ledger row.
4. Non-Functional Requirements
- Performance:
GET /api/subscriptions/planperforms at most two DB reads (active subscription + user's default plan) plus the seven-row cadence-allowance projection that is purely computed in memory. No caching layer is currently inserted between the controller and the repository. P95 < 200 ms is comfortably achievable in a healthy DB. - Reliability:
seedPlansruns on every module bootstrap; an upsert failure is FATAL (it propagates out ofonModuleInit, blocking module init). This is intentional — a partial seed produces inconsistent plan resolution. - Security & privacy: The controller is mounted behind
AuthSessionGuard, so plan reads/writes always require a valid session.users.defaultPlanIdis the only column the user can mutate via this surface;UserSubscriptionrows (status,currentPeriodEnd,cancelAtPeriodEnd,paymentMethodMeta) are NOT exposed to the user. - Observability: The service emits a single warn-level log line when
the configured default plan is missing
(
Subscription plan <code> not found, falling back to FREE). No activity-log emission is produced for plan changes today (see §6 OQ-2). - Compatibility: Plan codes are a closed enum
(
SubscriptionPlanCode). Adding a tier requires (1) adding the enum value inpackages/agent/src/entities/types.ts, (2) adding a row toPLAN_SEED_DATAinsubscription.service.ts, (3) extending the recommendation helper. TheallowedCadencesJSON column is asimple-jsonarray, so adding a newWorkScheduleCadencevalue is additive (forward-only — Constitution Principle V).
5. Key Entities & Domain Concepts
| Entity / concept | Description |
|---|---|
SubscriptionPlan | Catalog row keyed by code (SubscriptionPlanCode). Holds displayName, maxWorks, allowedCadences[], monthlyPrice, overagePricePerRun, currency, active. |
UserSubscription | Per-user active/canceled/past_due/trialing record linking a user to a plan via planId/planCode, with optional currentPeriodEnd, cancelAtPeriodEnd, paymentMethodMeta. |
UsageLedgerEntry | Per-billable-event row: userId, workId, optional scheduleId, triggerType (manual/scheduled), billingMode (subscription/usage), units, amountCents, currency, status (pending/queued_for_settlement/paid/canceled), optional generationHistoryId, metadata. |
SubscriptionPlanCode | Closed enum: FREE = 'free', STANDARD = 'standard', PREMIUM = 'premium'. |
SubscriptionStatus | Closed enum: ACTIVE, CANCELED, PAST_DUE, TRIALING. Only ACTIVE is consulted by findActiveByUser. |
SubscriptionBillingProvider | Closed enum on UserSubscription: STRIPE (default), MANUAL. Today the platform-side billing-provider abstraction (BillingProvider) is independent of this column. |
UsageLedgerTriggerType | Closed enum: MANUAL, SCHEDULED. The current consumer (WorkScheduleService) writes SCHEDULED exclusively. |
UsageLedgerStatus | Closed enum: PENDING (default), QUEUED_FOR_SETTLEMENT, PAID, CANCELED. recordUsage writes the default; promotion to QUEUED_FOR_SETTLEMENT is via markQueued(ids). |
WorkScheduleBillingMode | Closed enum imported from @ever-works/contracts/api: SUBSCRIPTION, USAGE. Stored on WorkSchedule.billingMode. |
WorkScheduleAllowedCadence | DTO: { cadence, allowed, payPerUse, reason? }. The shape getCadenceAllowances returns and the API surfaces. |
BillingProvider | Abstract NestJS provider with getDefaultCurrency(): string and recordUsageCharge(entry): Promise<void> (no-op default). |
ManualBillingProvider | Default registered impl: returns config.billing.getDefaultCurrency(), no remote charge. |
6. Out of Scope
- Stripe webhook handling (
POST /api/subscriptions/webhook) — the architecture spec describes one but no controller exists yet. - Stripe Checkout / portal redirection —
createCheckoutSessionis mentioned in the architecture spec but not implemented. - Per-feature gating (custom domains, member invites, agent-pipeline) —
the plan model has fields for these in the architecture spec
(
canInviteMembers,canUseCustomDomains,canUseAgentPipeline) but the entity and service do NOT carry them today. - Plan-tier discovery API — there is no
GET /api/subscriptions/planscatalog endpoint; clients hard-code the plan code list. - The current
FREEseed temporarily listsALL_CADENCESasallowedCadences(with the production list commented out). This is a deliberate "everything is free for now" override; restoring the production gate by uncommenting[WorkScheduleCadence.MONTHLY]is a follow-up captured intasks.mdas T18. - Trigger.dev retry of
failedledger entries — referenced in the architecture spec; no implementation today. - Activity-log emission on plan change — not currently produced.
7. Acceptance Criteria
-
GET /api/subscriptions/planreturns{ enabled: false, plan: null }whenSUBSCRIPTIONS_ENABLEDis unset / not'true'. -
GET /api/subscriptions/planreturns the resolved plan with the seven-cadence allowance grid when subscriptions are enabled. -
POST /api/subscriptions/planrejects with HTTP 400 when subscriptions are disabled, BEFORE any user lookup runs. -
POST /api/subscriptions/planupdatesusers.defaultPlanIdto the row matching the normalised plan code and returns the same envelope asGET. -
POST /api/subscriptions/planrejects with HTTP 400 when theplanCodebody is not a member ofSubscriptionPlanCode(validation pipe). -
assignPlanToUserthrowsNotFoundExceptionwhen the resolved plan code is not present in the table. -
resolvePlanForUserwalks the active-subscription → default-plan → config-default → FREE-fallback chain, with the warn log emitted on the FREE-fallback branch. -
requiresUsageBillingreturnsfalsewhen subscriptions are disabled, returnsfalsewhen the cadence is in the plan's allowed set, and otherwise returnsbillingMode !== 'usage'. -
getCadenceAllowancesproduces every cadence with the correct{ allowed, payPerUse, reason? }shape, and the entire grid is{ allowed: true, payPerUse: false }when subscriptions are disabled. -
UsageLedgerService.recordUsageshort-circuits tonullwhen subscriptions are disabled ORbillingMode !== 'usage'. -
UsageLedgerService.recordUsagewrites a row withunits = 1,amountCents = max(0, round(PAY_PER_USE_PRICE_USD * 100)),currencyfromBillingProvider.getDefaultCurrency(), andmetadata.cadence = schedule.cadence, then awaitsBillingProvider.recordUsageCharge(entry). -
WorkScheduleService.markRunCompletedrecords a ledger row;markRunFailed/markRunSkippeddo NOT. -
WorkScheduleService.updateSchedulerejects cadence transitions that failrequiresUsageBilling, and rejects creation whencountActiveByUser(user.id) >= plan.maxWorks. -
seedPlansis idempotent — re-running it overwrites the catalog rows with the latestPLAN_SEED_DATAshape, never produces duplicates. - All functional requirements have at least one passing unit or e2e test.
8. Open Questions
[NEEDS CLARIFICATION: OQ-1 —FREEseed currently allows ALL cadences (line 40 insubscription.service.ts:allowedCadences: ALL_CADENCES // for now everything is free). When does the production gate ([WorkScheduleCadence.MONTHLY]) ship? The behaviour spec describes the production-gated semantics, but the test suite must pin the current "everything free" behaviour or the tests will fail.][NEEDS CLARIFICATION: OQ-2 — Should plan changes emit an activity-log entry (e.g.user.subscription_plan_changed)? Today the controller does not emit one, while almost every other settings endpoint does.][NEEDS CLARIFICATION: OQ-3 —UsageLedgerService.recordUsagedoes not wrapbillingProvider.recordUsageCharge(entry)in a try/catch. A throwing billing provider crashes the originating run-completion path. The architecture spec describes a Trigger.dev retry loop forfailedentries, but the entries are never markedfailedtoday (status staysPENDING).][NEEDS CLARIFICATION: OQ-4 —UserSubscription.billingProviderdefaults toSTRIPEeven though no Stripe integration is wired. This is the column carrying the per-user provider; the platform-sideBillingProviderabstract class is separate. Should the default beMANUALuntil Stripe lands?][NEEDS CLARIFICATION: OQ-5 —assignPlanToUsermutatesuser.defaultPlan/user.defaultPlanIdin-place on the in-memory entity. This is convenient for the controller's immediatesummarizePlancall but can cause subtle staleness in callers that hold an older reference. Document or remove?]
9. Constitution Gates
- Plugin-first if introducing an external integration (Principle I) — N/A:
BillingProvideris an internal abstract class today, not a plugin. - Capability-driven resolution if touching cross-plugin behaviour (Principle II) — N/A: subscriptions resolve via per-user plan, not per-capability.
- Source-of-truth repos preserved (Principle III) — plan / subscription data is platform-side, never mirrored to user repos.
- Long-running work via Trigger.dev (Principle IV) — N/A today (no remote charge), but
BillingProvider.recordUsageChargeis the future Trigger.dev hook. - Schema changes ship as forward-only migrations (Principle V) —
subscription_plans,user_subscriptions,usage_ledger_entriesare additive tables; new plan codes / cadences are additive enum values. - Tests accompany the change (Principle VI) —
subscriptions.controller.spec.tscovers both endpoints; agent-package suites coversubscription.service.tsandusage-ledger.service.ts. - Secrets handled per
x-secretrules (Principle VII) —STRIPE_SECRET_KEY/STRIPE_WEBHOOK_SECRETread from env only; never logged. - Plugin counts touch the canonical doc only (Principle VIII) — N/A.
- Behaviour-first — no implementation in this spec (Principle IX) — implementation lives in
plan.md. - Backwards-compatible API/SDK/schema changes (Principle X) — no breaking change.
10. References
- Source:
apps/api/src/subscriptions/subscriptions.controller.tsapps/api/src/subscriptions/subscriptions.module.tspackages/agent/src/subscriptions/subscription.service.tspackages/agent/src/subscriptions/usage-ledger.service.tspackages/agent/src/subscriptions/billing/billing.provider.tspackages/agent/src/entities/subscription-plan.entity.tspackages/agent/src/entities/user-subscription.entity.tspackages/agent/src/entities/usage-ledger-entry.entity.tspackages/agent/src/database/repositories/subscription-plan.repository.tspackages/agent/src/database/repositories/user-subscription.repository.tspackages/agent/src/database/repositories/usage-ledger.repository.ts
- Related architecture:
docs/specs/architecture/subscriptions.md - Related features:
- User-facing docs: