chore: handle cancel with future starts_at

This commit is contained in:
Charlie Lamb
2026-05-08 10:39:04 +01:00
parent e761e15c5a
commit c7d9c7746f
10 changed files with 294 additions and 36 deletions

View File

@@ -1,7 +1,11 @@
import {
type AutumnBillingPlan,
CusProductStatus,
cp,
type FullCusProduct,
findMainActiveCustomerProductByGroup,
isCustomerProductCanceling,
isFutureStartDate,
type UpdateSubscriptionBillingContext,
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
@@ -39,6 +43,68 @@ const computeScheduledAddOnsToDelete = ({
});
};
const shouldDeleteCustomerProductBeforeBillingStarts = ({
customerProduct,
currentEpochMs,
}: {
customerProduct: FullCusProduct;
currentEpochMs: number;
}): boolean => {
if (customerProduct.status === CusProductStatus.Scheduled) return true;
const hasStripeSchedule = (customerProduct.scheduled_ids?.length ?? 0) > 0;
const hasStripeSubscription =
(customerProduct.subscription_ids?.length ?? 0) > 0;
return (
hasStripeSchedule &&
!hasStripeSubscription &&
isFutureStartDate(customerProduct.starts_at, currentEpochMs)
);
};
const computeScheduledCancelPlan = ({
billingContext,
plan,
}: {
billingContext: UpdateSubscriptionBillingContext;
plan: AutumnBillingPlan;
}): AutumnBillingPlan => {
const { customerProduct, fullCustomer } = billingContext;
const activeCustomerProduct = findMainActiveCustomerProductByGroup({
fullCus: fullCustomer,
productGroup: customerProduct.product.group,
internalEntityId: customerProduct.internal_entity_id ?? undefined,
});
const scheduledCancelPlan: AutumnBillingPlan = {
...plan,
updateCustomerProduct: undefined,
deleteCustomerProduct: customerProduct,
};
if (
!activeCustomerProduct ||
activeCustomerProduct.id === customerProduct.id ||
!isCustomerProductCanceling(activeCustomerProduct)
) {
return scheduledCancelPlan;
}
return {
...scheduledCancelPlan,
updateCustomerProduct: {
customerProduct: activeCustomerProduct,
updates: {
canceled: false,
canceled_at: null,
ended_at: null,
},
},
};
};
/**
* Computes and applies the cancel plan for a subscription.
*
@@ -64,6 +130,18 @@ export const computeCancelPlan = ({
});
}
if (
shouldDeleteCustomerProductBeforeBillingStarts({
customerProduct: billingContext.customerProduct,
currentEpochMs: billingContext.currentEpochMs,
})
) {
return computeScheduledCancelPlan({
billingContext,
plan,
});
}
// Step 1: Calculate when the subscription ends
const endOfCycleMs = computeEndOfCycleMs({ billingContext });

View File

@@ -12,7 +12,10 @@ export const handleCurrentCustomerProductErrors = ({
}) => {
const { customerProduct } = billingContext;
if (isCustomerProductScheduled(customerProduct)) {
if (
isCustomerProductScheduled(customerProduct) &&
!billingContext.cancelAction
) {
throw new RecaseError({
message: `Cannot update subscription for '${customerProduct.product.name}' because it is scheduled and not yet active`,
});

View File

@@ -80,5 +80,5 @@ export const handleUpdateSubscriptionErrors = async ({
handleUpdateCheckoutErrors({ billingContext });
// 12. Stripe billing plan errors (validate Stripe resources)
handleStripeBillingPlanErrors({ billingContext });
handleStripeBillingPlanErrors({ billingContext, billingPlan });
};

View File

@@ -91,12 +91,40 @@ const getScheduleScenario = ({
return "multi_phase";
};
const buildNoPhasesAction = ({
hasSubscription,
scheduleId,
}: {
hasSubscription: boolean;
scheduleId: string | undefined;
}): StripeSubscriptionScheduleResult => {
if (!scheduleId) return {};
if (hasSubscription) {
return {
scheduleAction: {
type: "release",
stripeSubscriptionScheduleId: scheduleId,
},
subscriptionCancelAt: null,
};
}
return {
scheduleAction: {
type: "cancel",
stripeSubscriptionScheduleId: scheduleId,
},
};
};
/**
* Builds the appropriate action for each scenario.
*/
const buildActionForScenario = ({
scenario,
hasSchedule,
hasSubscription,
scheduleId,
scheduledPhases,
cancelAtSeconds,
@@ -105,6 +133,7 @@ const buildActionForScenario = ({
}: {
scenario: ScheduleScenario;
hasSchedule: boolean;
hasSubscription: boolean;
scheduleId: string | undefined;
scheduledPhases: Stripe.SubscriptionScheduleUpdateParams.Phase[];
cancelAtSeconds: number | undefined;
@@ -113,7 +142,10 @@ const buildActionForScenario = ({
}): StripeSubscriptionScheduleResult => {
switch (scenario) {
case "no_phases":
return {};
return buildNoPhasesAction({
hasSubscription,
scheduleId,
});
case "single_indefinite":
// Product continues indefinitely: release schedule if exists, clear any cancel_at
@@ -285,6 +317,7 @@ export const buildStripeSubscriptionScheduleAction = ({
return buildActionForScenario({
scenario,
hasSchedule: !!stripeSubscriptionSchedule,
hasSubscription: !!stripeSubscription,
scheduleId: stripeSubscriptionSchedule?.id,
scheduledPhases,
cancelAtSeconds,

View File

@@ -1,5 +1,8 @@
import type {
BillingPlan,
UpdateSubscriptionBillingContext,
} from "@autumn/shared";
import { ErrCode, InternalError } from "@autumn/shared";
import type { UpdateSubscriptionBillingContext } from "@autumn/shared";
/**
* Validates Stripe-specific billing context requirements before executing billing plan.
@@ -7,16 +10,19 @@ import type { UpdateSubscriptionBillingContext } from "@autumn/shared";
*/
export const handleStripeBillingPlanErrors = ({
billingContext,
billingPlan,
}: {
billingContext: UpdateSubscriptionBillingContext;
billingPlan: BillingPlan;
}) => {
// If there's an existing subscription schedule, validate it has current_phase.start_date
// This is required for schedule updates (Stripe requires anchoring phases to the current phase start)
const { stripeSubscriptionSchedule } = billingContext;
const { subscriptionScheduleAction } = billingPlan.stripe;
if (subscriptionScheduleAction?.type !== "update") return;
if (!stripeSubscriptionSchedule?.subscription) return;
if (billingContext.stripeSubscriptionSchedule) {
const currentPhaseStart =
billingContext.stripeSubscriptionSchedule.current_phase?.start_date;
stripeSubscriptionSchedule.current_phase?.start_date;
if (!currentPhaseStart) {
throw new InternalError({
message:
@@ -24,5 +30,4 @@ export const handleStripeBillingPlanErrors = ({
code: ErrCode.InternalError,
});
}
}
};

View File

@@ -257,5 +257,14 @@ export const executeStripeSubscriptionScheduleAction = async ({
subscriptionScheduleAction.stripeSubscriptionScheduleId,
);
return null;
case "cancel":
ctx.logger.debug(
`[executeStripeSubscriptionScheduleAction] Canceling schedule: ${subscriptionScheduleAction.stripeSubscriptionScheduleId}`,
);
await stripeCli.subscriptionSchedules.cancel(
subscriptionScheduleAction.stripeSubscriptionScheduleId,
);
return null;
}
};

View File

@@ -1,7 +1,9 @@
import type {
BillingContext,
StripeSubscriptionScheduleAction,
} from "@autumn/shared";
import { formatSecondsToDate } from "@autumn/shared";
import type { AutumnContext } from "@server/honoUtils/HonoEnv";
import type { BillingContext } from "@autumn/shared";
import type { StripeSubscriptionScheduleAction } from "@autumn/shared";
import type Stripe from "stripe";
import { billingContextFormatPriceByStripePriceId } from "@/internal/billing/v2/utils/billingContextPriceLookup";
@@ -45,7 +47,10 @@ export const logSubscriptionScheduleAction = ({
billingContext: BillingContext;
subscriptionScheduleAction: StripeSubscriptionScheduleAction;
}): void => {
if (subscriptionScheduleAction.type === "release") {
if (
subscriptionScheduleAction.type === "release" ||
subscriptionScheduleAction.type === "cancel"
) {
ctx.logger.debug(
`[logSubscriptionScheduleAction] Action type: ${subscriptionScheduleAction.type}`,
);

View File

@@ -101,6 +101,116 @@ test.concurrent(`${chalk.yellowBright("starts_at: future attach creates schedule
await expectCustomerInvoiceCorrect({ customerId, count: 0 });
});
test.concurrent(`${chalk.yellowBright("starts_at: scheduled subscription can be canceled by customer_product_id")}`, async () => {
const customerId = "attach-start-date-cancel-scheduled";
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const { autumnV1, autumnV2_2, ctx, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [],
});
const startDate = addDays(advancedTo, 1).getTime();
await autumnV2_2.billing.attach<AttachParamsV1Input>({
customer_id: customerId,
plan_id: pro.id,
starts_at: startDate,
});
const scheduledCustomerProduct = await getCustomerProduct({
ctx,
customerId,
productId: pro.id,
});
const scheduleId = scheduledCustomerProduct.scheduled_ids?.[0];
if (!scheduleId)
throw new Error("Expected scheduled product to have schedule");
const preview = await autumnV1.subscriptions.previewUpdate({
customer_id: customerId,
customer_product_id: scheduledCustomerProduct.id,
cancel_action: "cancel_immediately",
});
expect(preview.total).toBe(0);
await autumnV1.subscriptions.update({
customer_id: customerId,
customer_product_id: scheduledCustomerProduct.id,
cancel_action: "cancel_immediately",
});
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
await expectCustomerProducts({
customer,
notPresent: [pro.id],
});
const stripeSchedule =
await ctx.stripeCli.subscriptionSchedules.retrieve(scheduleId);
expect(stripeSchedule.status).toBe("canceled");
await expectCustomerInvoiceCorrect({ customerId, count: 0 });
});
test.concurrent(`${chalk.yellowBright("starts_at: immediate-access future subscription cancels by deleting schedule")}`, async () => {
const customerId = "attach-start-date-cancel-immediate-access-v2";
const pro = products.pro({
id: "pro-immediate-access-cancel",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const { autumnV1, autumnV2_2, ctx, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [],
});
const startDate = addDays(advancedTo, 1).getTime();
await autumnV2_2.billing.attach<AttachParamsV1Input>({
customer_id: customerId,
plan_id: pro.id,
starts_at: startDate,
enable_plan_immediately: true,
});
const customerProduct = await getCustomerProduct({
ctx,
customerId,
productId: pro.id,
});
const scheduleId = customerProduct.scheduled_ids?.[0];
if (!scheduleId)
throw new Error("Expected immediate-access product to have schedule");
expect(customerProduct.status).toBe(CusProductStatus.Active);
expect(customerProduct.subscription_ids ?? []).toEqual([]);
await autumnV1.subscriptions.update({
customer_id: customerId,
customer_product_id: customerProduct.id,
cancel_action: "cancel_end_of_cycle",
});
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
await expectCustomerProducts({
customer,
notPresent: [pro.id],
});
const stripeSchedule =
await ctx.stripeCli.subscriptionSchedules.retrieve(scheduleId);
expect(stripeSchedule.status).toBe("canceled");
await expectCustomerInvoiceCorrect({ customerId, count: 0 });
});
test.concurrent(`${chalk.yellowBright("starts_at: future attach without payment method creates invoice schedule")}`, async () => {
const customerId = "attach-start-date-future-invoice";
const pro = products.pro({

View File

@@ -5,12 +5,14 @@
*/
import { expect, test } from "bun:test";
import { type ApiCustomerV3, ErrCode, FreeTrialDuration } from "@autumn/shared";
import { type ApiCustomerV3, FreeTrialDuration } from "@autumn/shared";
import {
expectCustomerProducts,
expectProductCanceling,
expectProductNotPresent,
expectProductScheduled,
} from "@tests/integration/billing/utils/expectCustomerProductCorrect";
import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect";
import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils";
import { items } from "@tests/utils/fixtures/items";
import { products } from "@tests/utils/fixtures/products";
@@ -18,20 +20,21 @@ import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 1: Cannot cancel a scheduled product
// TEST 1: Cancel a scheduled product
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - User is on Premium ($50/mo)
* - User downgrades to Pro ($20/mo) → Premium is canceling, Pro is scheduled
* - User tries to cancel Pro (the scheduled product)
* - User cancels Pro (the scheduled product)
*
* Expected Result:
* - Should return an error - cannot cancel a scheduled product
* - Pro scheduled attachment is removed
* - Premium is uncanceled and remains active
*/
test.concurrent(`${chalk.yellowBright("error: cannot cancel scheduled product")}`, async () => {
const customerId = "err-cancel-scheduled";
test.concurrent(`${chalk.yellowBright("cancel: scheduled product removes pending schedule")}`, async () => {
const customerId = "cancel-scheduled-product";
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
@@ -46,7 +49,7 @@ test.concurrent(`${chalk.yellowBright("error: cannot cancel scheduled product")}
items: [messagesItem, premiumPriceItem],
});
const { autumnV1 } = await initScenario({
const { autumnV1, ctx } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
@@ -70,16 +73,24 @@ test.concurrent(`${chalk.yellowBright("error: cannot cancel scheduled product")}
productId: pro.id,
});
// Try to cancel the scheduled product - should fail
await expectAutumnError({
errCode: ErrCode.InvalidRequest,
func: async () => {
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: pro.id,
cancel_action: "cancel_immediately",
});
},
const customerAfterCancel =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerProducts({
customer: customerAfterCancel,
active: [premium.id],
notPresent: [pro.id],
});
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});

View File

@@ -17,6 +17,10 @@ export const StripeSubscriptionScheduleActionSchema = z.discriminatedUnion(
type: z.literal("release"),
stripeSubscriptionScheduleId: z.string(),
}),
z.object({
type: z.literal("cancel"),
stripeSubscriptionScheduleId: z.string(),
}),
],
);