fix: mixed schedules

This commit is contained in:
johnyeo
2026-05-29 20:21:13 +01:00
parent 1228fe2ff9
commit d09c854142
2 changed files with 118 additions and 5 deletions

View File

@@ -10,6 +10,8 @@ import {
type UpdateSubscriptionV1Params,
} from "@autumn/shared";
import { all } from "better-all";
import type Stripe from "stripe";
import { stripeSubscriptionToScheduleId } from "@/external/stripe/subscriptions/utils/convertStripeSubscription";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { fetchStripeCustomerForBilling } from "./fetchStripeCustomerForBilling";
import { fetchStripeDiscountsForBilling } from "./fetchStripeDiscountsForBilling";
@@ -17,6 +19,13 @@ import { fetchStripeSubscriptionForBilling } from "./fetchStripeSubscriptionForB
import { fetchStripeSubscriptionScheduleForBilling } from "./fetchStripeSubscriptionScheduleForBilling";
import { fetchStripeTaxRateForBilling } from "./fetchStripeTaxRateForBilling";
const getScheduleSubscriptionId = (
stripeSubscriptionSchedule: Stripe.SubscriptionSchedule | undefined,
) => {
const subscription = stripeSubscriptionSchedule?.subscription;
return typeof subscription === "string" ? subscription : subscription?.id;
};
export const setupStripeBillingContext = async ({
ctx,
fullCustomer,
@@ -101,10 +110,9 @@ export const setupStripeBillingContext = async ({
? fetchStripeSubscriptionScheduleForBilling({
ctx,
fullCus: fullCustomer,
subscriptionScheduleId:
typeof localStripeSubscription?.schedule === "string"
? localStripeSubscription.schedule
: undefined,
subscriptionScheduleId: stripeSubscriptionToScheduleId({
stripeSubscription: localStripeSubscription,
}),
products: [],
targetCusProductId: targetCustomerProduct?.id,
})
@@ -130,9 +138,17 @@ export const setupStripeBillingContext = async ({
},
});
const stripeSubscriptionScheduleForContext =
stripeSubscription && stripeSubscriptionSchedule
? getScheduleSubscriptionId(stripeSubscriptionSchedule) ===
stripeSubscription.id
? stripeSubscriptionSchedule
: undefined
: stripeSubscriptionSchedule;
return {
stripeSubscription,
stripeSubscriptionSchedule,
stripeSubscriptionSchedule: stripeSubscriptionScheduleForContext,
stripeCustomer,
stripeDiscounts,
stripeTaxRate,

View File

@@ -0,0 +1,97 @@
import { expect, test } from "bun:test";
import { getSubscriptionId } from "@tests/integration/billing/utils/stripe/getSubscriptionId";
import { items } from "@tests/utils/fixtures/items";
import { products } from "@tests/utils/fixtures/products";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
import { isStripeSubscriptionCanceling } from "@/external/stripe/subscriptions/utils/classifyStripeSubscriptionUtils";
import { CusService } from "@/internal/customers/CusService";
test.concurrent(`${chalk.yellowBright("cancel EOC: ignores schedules from other subscriptions")}`, async () => {
const customerId = "cancel-eoc-mismatched-schedule";
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const monthlyAddon = products.base({
id: "monthly-addon",
isAddOn: true,
items: [items.monthlyPrice({ price: 7 })],
});
const annualAddon = products.base({
id: "annual-addon",
isAddOn: true,
items: [items.annualPrice({ price: 70 })],
});
const { autumnV1, ctx } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, monthlyAddon, annualAddon] }),
],
actions: [s.billing.attach({ productId: pro.id })],
});
await autumnV1.billing.multiAttach({
customer_id: customerId,
plans: [{ plan_id: monthlyAddon.id }, { plan_id: annualAddon.id }],
new_billing_subscription: true,
});
const proSubId = await getSubscriptionId({
ctx,
customerId,
productId: pro.id,
});
const monthlyAddonSubId = await getSubscriptionId({
ctx,
customerId,
productId: monthlyAddon.id,
});
const annualAddonSubId = await getSubscriptionId({
ctx,
customerId,
productId: annualAddon.id,
});
expect(proSubId).not.toBe(monthlyAddonSubId);
expect(monthlyAddonSubId).toBe(annualAddonSubId);
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: monthlyAddon.id,
cancel_action: "cancel_end_of_cycle",
});
const addonSubWithSchedule =
await ctx.stripeCli.subscriptions.retrieve(monthlyAddonSubId);
expect(addonSubWithSchedule.schedule).not.toBeNull();
const addonScheduleId = addonSubWithSchedule.schedule as string;
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: pro.id,
cancel_action: "cancel_end_of_cycle",
});
const proSubAfterCancel = await ctx.stripeCli.subscriptions.retrieve(proSubId);
const addonSubAfterProCancel =
await ctx.stripeCli.subscriptions.retrieve(monthlyAddonSubId);
expect(isStripeSubscriptionCanceling(proSubAfterCancel)).toBe(true);
expect(proSubAfterCancel.schedule).toBeNull();
expect(addonSubAfterProCancel.schedule).toBe(addonScheduleId);
const fullCustomer = await CusService.getFull({ ctx, idOrInternalId: customerId });
const proCustomerProduct = fullCustomer.customer_products.find(
(cp) => cp.product.id === pro.id,
);
const monthlyAddonCustomerProduct = fullCustomer.customer_products.find(
(cp) => cp.product.id === monthlyAddon.id,
);
expect(proCustomerProduct?.scheduled_ids ?? []).toEqual([]);
expect(monthlyAddonCustomerProduct?.scheduled_ids).toEqual([addonScheduleId]);
});