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 { import {
type AutumnBillingPlan, type AutumnBillingPlan,
CusProductStatus,
cp, cp,
type FullCusProduct, type FullCusProduct,
findMainActiveCustomerProductByGroup,
isCustomerProductCanceling,
isFutureStartDate,
type UpdateSubscriptionBillingContext, type UpdateSubscriptionBillingContext,
} from "@autumn/shared"; } from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv"; 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. * 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 // Step 1: Calculate when the subscription ends
const endOfCycleMs = computeEndOfCycleMs({ billingContext }); const endOfCycleMs = computeEndOfCycleMs({ billingContext });

View File

@@ -12,7 +12,10 @@ export const handleCurrentCustomerProductErrors = ({
}) => { }) => {
const { customerProduct } = billingContext; const { customerProduct } = billingContext;
if (isCustomerProductScheduled(customerProduct)) { if (
isCustomerProductScheduled(customerProduct) &&
!billingContext.cancelAction
) {
throw new RecaseError({ throw new RecaseError({
message: `Cannot update subscription for '${customerProduct.product.name}' because it is scheduled and not yet active`, 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 }); handleUpdateCheckoutErrors({ billingContext });
// 12. Stripe billing plan errors (validate Stripe resources) // 12. Stripe billing plan errors (validate Stripe resources)
handleStripeBillingPlanErrors({ billingContext }); handleStripeBillingPlanErrors({ billingContext, billingPlan });
}; };

View File

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

View File

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

View File

@@ -257,5 +257,14 @@ export const executeStripeSubscriptionScheduleAction = async ({
subscriptionScheduleAction.stripeSubscriptionScheduleId, subscriptionScheduleAction.stripeSubscriptionScheduleId,
); );
return null; 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 { formatSecondsToDate } from "@autumn/shared";
import type { AutumnContext } from "@server/honoUtils/HonoEnv"; 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 type Stripe from "stripe";
import { billingContextFormatPriceByStripePriceId } from "@/internal/billing/v2/utils/billingContextPriceLookup"; import { billingContextFormatPriceByStripePriceId } from "@/internal/billing/v2/utils/billingContextPriceLookup";
@@ -45,7 +47,10 @@ export const logSubscriptionScheduleAction = ({
billingContext: BillingContext; billingContext: BillingContext;
subscriptionScheduleAction: StripeSubscriptionScheduleAction; subscriptionScheduleAction: StripeSubscriptionScheduleAction;
}): void => { }): void => {
if (subscriptionScheduleAction.type === "release") { if (
subscriptionScheduleAction.type === "release" ||
subscriptionScheduleAction.type === "cancel"
) {
ctx.logger.debug( ctx.logger.debug(
`[logSubscriptionScheduleAction] Action type: ${subscriptionScheduleAction.type}`, `[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 }); 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 () => { test.concurrent(`${chalk.yellowBright("starts_at: future attach without payment method creates invoice schedule")}`, async () => {
const customerId = "attach-start-date-future-invoice"; const customerId = "attach-start-date-future-invoice";
const pro = products.pro({ const pro = products.pro({

View File

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