chore: handle cancel with future starts_at
This commit is contained in:
@@ -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 });
|
||||
|
||||
|
||||
@@ -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`,
|
||||
});
|
||||
|
||||
@@ -80,5 +80,5 @@ export const handleUpdateSubscriptionErrors = async ({
|
||||
handleUpdateCheckoutErrors({ billingContext });
|
||||
|
||||
// 12. Stripe billing plan errors (validate Stripe resources)
|
||||
handleStripeBillingPlanErrors({ billingContext });
|
||||
handleStripeBillingPlanErrors({ billingContext, billingPlan });
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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(),
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user