chore: handle cancel with future starts_at
This commit is contained in:
@@ -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 });
|
||||||
|
|
||||||
|
|||||||
@@ -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`,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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}`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user