9.6 KiB
Cancel Implementation Plan
This document outlines the implementation plan for adding cancel support to the update subscription endpoint.
Overview
The cancel parameter allows users to:
- Schedule a subscription cancellation at the end of the current billing cycle (
'end_of_cycle') - Cancel immediately (
'immediately')
These can be optionally combined with other subscription updates (like custom plan changes or quantity updates).
API Parameter
cancel: z.enum(["immediately", "end_of_cycle"]).nullable().optional()
'end_of_cycle'- Schedule cancellation at cycle end'immediately'- Cancel nownull- Uncancel (future work)undefined- No cancel action
Key Behaviors
1. cancel: 'end_of_cycle'
- Sets
canceled: true,canceled_at: currentEpochMs,ended_at: cycleEndon customer product - Inserts scheduled default product (starts at
cycleEnd) for main products - Deletes any existing scheduled product in the group
2. cancel: 'immediately'
- Sets
canceled: true,canceled_at: currentEpochMs,ended_at: currentEpochMs,status: Expired - Inserts active default product for main products
- Deletes any existing scheduled product in the group
3. Combining with items (custom plan)
- Cancel updates are applied to the NEW inserted customer products
- Example:
cancel: 'end_of_cycle'+items= switch to custom plan AND schedule cancellation
4. Default products
- Default products are FREE - no Stripe subscription needed
- Add-ons do NOT trigger default products
5. Existing scheduled products
- If there's already a scheduled customer product (downgrade in progress), it gets deleted
- Uses
findMainScheduledCustomerProductByGroup
Architecture
Compute Layer Structure
server/src/internal/billing/v2/updateSubscription/compute/cancel/
├── computeCancelPlan.ts # Orchestrator - main entry point
├── computeEndOfCycleMs.ts # Step 1: Calculate cycle end timestamp
├── computeCancelUpdates.ts # Step 2: Build cancel field updates
├── computeDefaultCustomerProduct.ts # Step 3: Create default product to insert
├── computeCustomerProductToDelete.ts # Step 4: Find scheduled product to delete
└── applyCancelPlan.ts # Apply computed values to the plan
Flow
computeCancelPlan({ ctx, billingContext, params, plan }) {
if (!params.cancel) return plan;
// Step 1: Calculate when the subscription ends
const endOfCycleMs = computeEndOfCycleMs({ ... });
// Step 2: Build cancel updates for customer product
const cancelUpdates = computeCancelUpdates({ cancelMode, endOfCycleMs, currentEpochMs });
// Step 3: Create default product (if applicable)
const defaultProduct = computeDefaultCustomerProduct({ ..., endOfCycleMs });
// Step 4: Find existing scheduled product to delete
const productToDelete = computeCustomerProductToDelete({ ... });
// Apply all computed values to the plan
return applyCancelPlan({ plan, cancelUpdates, defaultProduct, productToDelete });
}
Implementation Status
Completed
1. Updated params schema
File: shared/api/billing/updateSubscription/updateSubscriptionV0Params.ts
cancel: z.enum(["immediately", "end_of_cycle"]).nullable().optional(),
2. Updated AutumnBillingPlan schema
File: server/src/internal/billing/v2/types/autumnBillingPlan.ts
- Changed cancel fields from
.optional()to.nullish()to support setting tonullfor uncancel
3. Updated setupDefaultProductContext
File: server/src/internal/billing/v2/updateSubscription/setup/setupDefaultProductContext.ts
- Now checks for
params.cancelinstead of oldparams.cancel_end_of_cycle
4. Cancel compute layer (DONE)
Folder: server/src/internal/billing/v2/updateSubscription/compute/cancel/
| File | Status | Description |
|---|---|---|
computeEndOfCycleMs.ts |
Done | Calculate cycle end timestamp |
computeCancelUpdates.ts |
Done | Build cancel field updates |
computeDefaultCustomerProduct.ts |
Done | Create default product to insert |
computeCustomerProductToDelete.ts |
Done | Find scheduled product to delete |
applyCancelPlan.ts |
Done | Apply computed values to plan |
computeCancelPlan.ts |
Done | Orchestrator function |
5. Integrated into computeUpdateSubscriptionPlan
File: server/src/internal/billing/v2/updateSubscription/compute/computeUpdateSubscriptionPlan.ts
- Calls
computeCancelPlanafter computing the base plan (quantity/custom)
Stripe Integration
Overview
The Stripe layer needs to handle cancellation by:
- Setting
cancel_attimestamp on the subscription (for simple cancel scenarios) - Using subscription schedules with
end_behavior: "cancel"(for multi-phase scenarios) - Releasing existing schedules when transitioning to simple cancel
Key Insight: Phase-Based Detection
When we build Stripe phases from customer products:
- Phase 1: Current products with items (now →
ended_at) - Phase 2: Empty (no items) if all products are canceling
If Phase 2 is empty, it signals a "cancel at end" scenario. The cancel_at timestamp is Phase 2's start_date.
Scenarios
Scenario 1: Simple cancel (no future phases with items)
- Customer has Pro plan, cancels at end of cycle
- No other products/entities continue
- Stripe action: Set
cancel_aton subscription directly
Scenario 2: Cancel with schedule (multi-entity or downgrade)
- Entity A on Pro, Entity B on Pro
- Entity A cancels at end of cycle
- Stripe action: Update schedule with Phase 1 (both entities) → Phase 2 (Entity B only)
Scenario 3: Cancel when schedule exists (but results in simple cancel)
- Schedule exists managing a downgrade
- User cancels the whole thing
- Stripe action: Release schedule + set
cancel_aton subscription
Implementation
1. buildStripeSubscriptionScheduleAction - New Return Type
interface SubscriptionScheduleBuildResult {
scheduleAction?: StripeSubscriptionScheduleAction;
subscriptionCancelAt?: number; // Unix ms timestamp to set on subscription
}
The function detects:
- If trailing empty phase exists →
shouldCancelAtEnd = true - If only 1 phase starting now + shouldCancelAtEnd:
- Release schedule (if exists) + return
subscriptionCancelAt
- Release schedule (if exists) + return
- If multiple phases with items:
- Return schedule action with
end_behavior: "cancel"if shouldCancelAtEnd
- Return schedule action with
2. New release Action Type
Added to StripeSubscriptionScheduleActionSchema:
z.object({
type: z.literal("release"),
stripeSubscriptionScheduleId: z.string(),
})
3. cancel_at in Subscription Actions
Both buildStripeSubscriptionUpdateAction and buildStripeSubscriptionCreateAction accept subscriptionCancelAt param and include it in Stripe params.
For updates, only set if different from current stripeSubscription.cancel_at.
Files Modified
| File | Change |
|---|---|
types/stripeBillingPlan/stripeSubscriptionScheduleAction.ts |
Add release action type |
actionBuilders/buildStripeSubscriptionScheduleAction.ts |
New return type, detect cancel scenarios |
actionBuilders/evaluateStripeBillingPlan.ts |
Pass subscriptionCancelAt to subscription builder |
actionBuilders/buildStripeSubscriptionAction.ts |
Pass subscriptionCancelAt to create/update builders |
utils/subscriptions/buildStripeSubscriptionUpdateAction.ts |
Add cancel_at to params |
utils/subscriptions/buildStripeSubscriptionCreateAction.ts |
Add cancel_at to params |
execute/executeStripeSubscriptionScheduleAction.ts |
Handle release action |
Execution Order
Current order (subscription → schedule) is maintained. If Stripe doesn't allow setting cancel_at while schedule exists, we'll revisit.
Remaining Work (Future)
1. Validation / Error Handling
File: server/src/internal/billing/v2/updateSubscription/errors/handleUpdateSubscriptionErrors.ts
Add validation for:
- Cannot cancel free products with
'end_of_cycle'(use'immediately'instead) - Cannot cancel if already canceled (or handle gracefully)
2. Execute Layer - Persist Cancel Fields
Ensure the update logic persists cancel fields to DB.
3. Uncancel (cancel: null)
- Clear cancel fields
- Delete scheduled default product
- Unset
cancel_aton Stripe subscription
4. Proration for cancel: 'immediately'
- Add
prorateoption support
5. Atomicity for schedule release + subscription update
- Currently executing subscription action before schedule release
- If Stripe requires schedule release first, need to handle potential failure state
Test Cases
- Basic
cancel: 'end_of_cycle'- Cancel a paid subscription at end of cycle - Basic
cancel: 'immediately'- Cancel a paid subscription immediately - Cancel + custom plan - Update to custom plan AND set cancel
- Cancel with existing scheduled product - Should delete the scheduled product
- Cancel add-on - Should NOT create default product
- Cancel free product - Should throw error for
'end_of_cycle'
Dependencies
getLargestIntervalfromserver/src/internal/products/prices/priceUtils/priceIntervalUtils.tsgetCycleEndfrom@autumn/sharedcusProductToPricesfrom@autumn/sharedfindMainScheduledCustomerProductByGroupfrom@autumn/sharedinitFullCustomerProductfromserver/src/internal/billing/v2/utils/initFullCustomerProduct/initFullCustomerProduct.tsgetFreeDefaultProductByGroupfromserver/src/internal/customers/cusProducts/cusProductUtils.ts