Task Breakdown: Subscriptions & Plan Gating
Feature ID: subscriptions
Status: Done (Retrospective)
Last updated: 2026-05-08
Phase 1 — Plan Catalog & Seed
- T1.
SubscriptionPlanentity atpackages/agent/src/entities/subscription-plan.entity.tswithid(uuid PK),code(SubscriptionPlanCodeunique),displayName,maxWorks,allowedCadences(simple-jsonarray ofWorkScheduleCadence),monthlyPrice/overagePricePerRun(decimal(10,2)),currency(default'usd'),active(defaulttrue),createdAt/updatedAt. - T2.
SubscriptionPlanRepository(findByCode,findAllActive,upsert) atpackages/agent/src/database/repositories/subscription-plan.repository.ts. - T3.
PLAN_SEED_DATAtable inpackages/agent/src/subscriptions/subscription.service.tswith three rows (free/standard/premium) —maxWorks(1 / 5 / 15), tier-appropriateallowedCadences,monthlyPrice('0'/'29'/'99'),overagePricePerRun('10'/'8'/'0'). - T4.
SubscriptionService.onModuleInit→seedPlans():Promise.all(PLAN_SEED_DATA.map(plan => upsert({ ...plan, currency: config.billing.getDefaultCurrency(), active: true }))). Idempotent viafindByCodelookup insideupsert.
Phase 2 — Per-User Subscription & Usage Schema
- T5.
UserSubscriptionentity atpackages/agent/src/entities/user-subscription.entity.tswithuserId(FK +ON DELETE CASCADE),planCode,planId(FK, eager-loaded),status(SubscriptionStatusenum, defaultACTIVE),billingProvider(SubscriptionBillingProviderenum, defaultSTRIPE),currentPeriodEnd(nullable timestamp),cancelAtPeriodEnd,paymentMethodMeta(json nullable). Composite indexes on(userId, status)and(planCode). - T6.
UserSubscriptionRepository(findActiveByUser,listByUser,createOrUpdate,cancel) atpackages/agent/src/database/repositories/user-subscription.repository.ts. - T7.
UsageLedgerEntryentity atpackages/agent/src/entities/usage-ledger-entry.entity.tswithuserId/workId(FK +ON DELETE CASCADE),scheduleId(FK +ON DELETE SET NULL),triggerType(UsageLedgerTriggerTypeenum),billingMode(WorkScheduleBillingModeenum),units(default1),amountCents(default0),currency(default'usd'),status(UsageLedgerStatus, defaultPENDING),generationHistoryId(FK nullable),metadata(json nullable). Indexes on(userId, status),(workId),(createdAt),(scheduleId). - T8.
UsageLedgerRepository(record,findPendingByUser,markQueued,getUsageSummary) atpackages/agent/src/database/repositories/usage-ledger.repository.ts.
Phase 3 — Resolution Service
- T9.
SubscriptionService.isEnabled()readsconfig.subscriptions.isEnabled()(env varSUBSCRIPTIONS_ENABLED === 'true'). - T10.
SubscriptionService.getActiveSubscription(userId)→userSubscriptionRepository.findActiveByUser(userId). - T11.
SubscriptionService.resolvePlanForUser(user)four-level chain: subscriptions-disabled →resolveDefaultPlan; active subscription withplan→subscription.plan;user.defaultPlan→ that plan; otherwise →resolveDefaultPlan. - T12.
SubscriptionService.resolveDefaultPlan()readsconfig.subscriptions.getDefaultPlanCode()(default'free'), normalises vianormalizePlanCode, looks up the plan; on miss logs warn (Subscription plan <code> not found, falling back to FREE) and falls back toFREE; throws when neither resolves. - T13.
SubscriptionService.normalizePlanCode(value):value?.toLowerCase(); if it matches aSubscriptionPlanCodevalue, return it; otherwise returnSubscriptionPlanCode.FREE. - T14.
SubscriptionService.getCadenceAllowances(user): when disabled, returns the seven-cadence grid as{ allowed: true, payPerUse: false }; otherwise resolves the plan, builds aSetfromplan.allowedCadences, and projects every cadence with{ allowed, payPerUse: !allowed, reason }wherereasoninterpolatesrecommendationForCadence(cadence)(Premiumfor hourly/3h/8h,Standardfor 12h/daily/weekly,Freefor monthly). - T15.
SubscriptionService.getDefaultCadence(plan)returns the LAST entry ofplan.allowedCadencesorMONTHLYwhen empty. - T16.
SubscriptionService.requiresUsageBilling(cadence, plan, billingMode): returnsfalsewhen subscriptions are disabled OR cadence is in the plan's allowed set; otherwise returnsbillingMode !== USAGE.
Phase 4 — Plan Assignment
- T17.
SubscriptionService.assignPlanToUser(user, planCode): throwsBadRequestException('Subscriptions are disabled')when disabled; normalises the code; throwsNotFoundException('Plan not found')on missing plan; updatesusers.defaultPlanId; mutatesuser.defaultPlan/user.defaultPlanIdin place; returns the resolved plan. - T18. OPEN — Restore the production
FREEcadence gate (uncomment[WorkScheduleCadence.MONTHLY]and remove theALL_CADENCESoverride on line 40 ofsubscription.service.ts). Requires updatingsubscription.service.spec.tsto assert the tightened gate. Tracked as OQ-1. - T19.
SubscriptionService.summarizePlan(user):Promise.all([resolvePlanForUser(user), getCadenceAllowances(user)])→{ plan, allowances, enabled: isEnabled() }.
Phase 5 — HTTP Surface
- T20.
SubscriptionsControlleratapps/api/src/subscriptions/subscriptions.controller.tsmounted on/api/subscriptionsbehindAuthSessionGuardwith@ApiTags('Subscriptions')+@ApiBearerAuth('JWT-auth'). - T21.
GET /planrunsauthService.getUser(auth.userId)→subscriptionService.summarizePlan(user); whensummary.enabled === falsereturns{ status: 'success', enabled: false, plan: null }; otherwise returns{ status: 'success', enabled: true, plan: { code: summary.plan.code, name: summary.plan.displayName, allowedCadences: summary.allowances } }. - T22.
POST /planUpdateSubscriptionPlanDtowith@IsEnum(SubscriptionPlanCode)onplanCode. Body throws 400 via the validation pipe when the code is unknown. - T23.
POST /plancontroller body: throwsBadRequestException('Subscriptions are disabled')when disabled (BEFORE any user lookup); resolves user viaauthService.getUser; callsassignPlanToUser; callssummarizePlan(user); returns{ status: 'success', enabled: true, plan: { code: plan.code, name: plan.displayName, allowedCadences: summary.allowances } }.
Phase 6 — Usage-Ledger Pipeline
- T24.
BillingProviderabstract class atpackages/agent/src/subscriptions/billing/billing.provider.tswithgetDefaultCurrency(): stringandrecordUsageCharge(entry: UsageLedgerEntry): Promise<void>(no-op default). - T25.
ManualBillingProvider(@Injectable) returnsconfig.billing.getDefaultCurrency()fromgetDefaultCurrencyand inherits the no-oprecordUsageCharge. - T26.
UsageLedgerService.recordUsage(options): short-circuits tonullwhen subscriptions are disabled ORoptions.billingMode !== USAGE. Otherwise:amountCents = config.subscriptions.getPayPerUsePriceCents(),entry = await ledgerRepository.record({...})with{ userId, workId, scheduleId: schedule?.id, triggerType, billingMode, units: 1, amountCents, currency: billingProvider.getDefaultCurrency(), generationHistoryId, metadata: { cadence: schedule?.cadence } }, thenawait billingProvider.recordUsageCharge(entry), then returnentry. - T27. Module wiring at
packages/agent/src/subscriptions/subscriptions.module.ts: importsDatabaseModule; providersSubscriptionService,UsageLedgerService,{ provide: BillingProvider, useClass: ManualBillingProvider }; exports all three so consumers can injectSubscriptionService/UsageLedgerService/BillingProvider. - T28.
apps/apire-export atapps/api/src/subscriptions/subscriptions.module.ts: importsAuthModuleand the agentSubscriptionsModule; registersSubscriptionsController.
Phase 7 — Schedule Integration (Cross-Cutting Consumer)
- T29.
WorkScheduleService.getSchedule(workId, user)resolves[schedule, allowances, plan, readiness]in parallel and emits a DTO withsubscriptionsEnabledfromsubscriptionService.isEnabled(). - T30.
WorkScheduleService.updateSchedule(workId, dto, user)runssubscriptionService.requiresUsageBilling(cadence, plan, billingMode)BEFORE persistence — whentrue, throwsBadRequestException({ status: 'error', message: 'Selected cadence is not available on your plan. Switch to pay-per-use to continue.' }). - T31.
WorkScheduleService.updateSchedule(workId, dto, user)enforcesplan.maxWorkswhen subscriptions are enabled and the request creates-or-activates: throwsBadRequestException({ status: 'error', code: 'PLAN_LIMIT_EXCEEDED', message: 'Your <DisplayName> plan allows up to <maxWorks> scheduled works.' })whenscheduleRepository.countActiveByUser(user.id) >= plan.maxWorks. - T32.
WorkScheduleService.updateScheduledefaults the cadence tosubscriptionService.getDefaultCadence(plan)when neitherdto.cadencenorexisting?.cadenceis set; throwsBadRequestException('Cadence is required to enable scheduled updates')when nothing resolves. - T33.
WorkScheduleService.markRunCompletedcallsusageLedgerService.recordUsage({ userId, workId, schedule, triggerType: SCHEDULED, billingMode: schedule.billingMode, generationHistoryId }). - T34.
WorkScheduleService.markRunFailedandmarkRunSkippeddo NOT callrecordUsage(failed / skipped runs are free).
Phase 8 — Tests
- T35.
subscriptions.controller.spec.ts(9 tests, PR #496) —getPlanenabled-false envelope, mapped-success envelope,getUser+summarizePlanerror propagation;updatePlanBadRequest when disabled (nogetUser/assignPlanToUser), mapped-success envelope, plan source =assignPlanToUserresponse, error propagation. - T36.
subscription.service.spec.ts— coversisEnabled,seedPlansupsert calls,resolvePlanForUserfour-level chain (subscription → defaultPlan → config default → FREE fallback),getCadenceAllowancesallowed/payPerUse/reason projection (both enabled and disabled),requiresUsageBillingtruth table (disabled, allowed, billingMode-USAGE, billingMode-non-USAGE),getDefaultCadence(non-empty + empty),assignPlanToUser(disabled / normalisation / missing plan / happy path with in-place mutation),resolveDefaultPlanwarn log on FREE fallback, throw when both default and FREE missing. - T37.
usage-ledger.service.spec.ts—recordUsageshort-circuits tonullwhen subscriptions are disabled, short-circuits whenbillingMode !== USAGE, writes a row with the correctamountCents/currency/metadata.cadenceshape and forwards optionalgenerationHistoryId, fans out tobillingProvider.recordUsageCharge(entry), returns the entry. - T38.
work-schedule.service.spec.ts(consumer side) — pins the cadence-gate (BadRequestExceptionw/Switch to pay-per-use to continue.),PLAN_LIMIT_EXCEEDED(count vsmaxWorks),markRunCompletedledger write,markRunFailed/markRunSkippedno-write. - T39. FOLLOW-UP — Add a Postgres-container integration test
that exercises the full ledger pipeline: enable subscriptions,
assign
STANDARDplan, switch a schedule toHOURLY+billingMode = 'usage', runmarkRunCompleted, assert oneusage_ledger_entriesrow withamountCents = 500,triggerType = 'scheduled',metadata.cadence = 'hourly',status = 'pending'. Currently covered only at unit level with mocked repositories. - T40. FOLLOW-UP — Add an e2e test against
GET/POST /api/subscriptions/plan(currently controller-level unit suite only) verifying theAuthSessionGuard401 path, the@IsEnum400 path, and the round-trip plan switch.
Phase 9 — Open Questions / Outstanding Work
- T41. OQ-1 — Restore the production
FREEcadence gate (see T18). Current behaviour is "everything is free for now"; the spec describes the production-gated semantics. - T42. OQ-2 — Decide whether to emit an activity-log entry on
plan change. If yes, fire-and-forget
activityLogService.log({ userId: user.id, action: 'user.subscription_plan_changed', actionType: SETTINGS_UPDATED, details: { oldCode, newCode } }).catch(() => {})fromupdatePlanAFTERassignPlanToUserresolves. - T43. OQ-3 — Wrap
billingProvider.recordUsageCharge(entry)in a try/catch inUsageLedgerService.recordUsage: on failure, mark the entry as'failed'(not'pending') so a future Trigger.dev retry task can pick it up, and log vialogger.error. - T44. OQ-4 — Default
UserSubscription.billingProvidertoMANUALuntil Stripe lands; flip toSTRIPEonly when the StripeBillingProviderimpl is registered. - T45. OQ-5 — Decide whether
assignPlanToUsershould refetch the user instead of mutating in place. Today the mutation saves a DB roundtrip in the controller's hot path; the trade-off is potential staleness in callers that hold an older reference.
Definition of Done
- All FRs in
spec.mdmap to a passing test. - The HTTP surface is covered by both unit and (eventually) e2e tests.
- The agent-package services have unit suites for both
SubscriptionServiceandUsageLedgerService. - The
WorkScheduleServiceconsumer pins both the cadence gate and the post-completion ledger write. COVERAGE-TRACKER.mdreflects the spec landing under "Pending — Medium Priority → Spec Kit features that need a spec".