fix: diff interval upgrade

This commit is contained in:
John Yeo
2026-04-03 21:57:33 +01:00
parent da869f7f90
commit d4dbe8ce2a
4 changed files with 134 additions and 17 deletions

View File

@@ -5,6 +5,8 @@ import {
type FullProduct,
findMainActiveCustomerProductByGroup,
findMainScheduledCustomerProductByGroup,
getProductBaseInterval,
intervalsDifferent,
isOneOffProduct,
isProductUpgrade,
} from "@autumn/shared";
@@ -53,13 +55,31 @@ export const setupAttachTransitionContext = ({
cusProduct: currentCustomerProduct,
});
const currentBaseInterval = getProductBaseInterval({
prices: currentPrices,
});
const newBaseInterval = getProductBaseInterval({
prices: attachProduct.prices,
});
const baseIntervalsAreDifferent =
currentBaseInterval &&
newBaseInterval &&
intervalsDifferent({
intervalA: currentBaseInterval,
intervalB: newBaseInterval,
});
if (baseIntervalsAreDifferent) {
planTiming = "immediate";
} else {
const isUpgrade = isProductUpgrade({
prices1: currentPrices,
prices2: attachProduct.prices,
});
planTiming = isUpgrade ? "immediate" : "end_of_cycle";
}
}
// Override if plan_schedule param is provided
if (planScheduleOverride) {

View File

@@ -556,3 +556,89 @@ test.concurrent(`${chalk.yellowBright("immediate-switch-basic 6: invoice line it
notPresent: [pro.id],
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 7: Pro Annual to Premium Monthly (interval change = immediate)
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer has pro annual ($200/year)
* - Switch to premium monthly ($50/mo)
*
* Expected Result:
* - Premium is active immediately (different billing interval = always immediate)
* - Pro annual is removed
* - Credit for unused annual applied against premium charge
*/
test.concurrent(`${chalk.yellowBright("immediate-switch-basic 7: pro annual to premium monthly (interval change)")}`, async () => {
const customerId = "imm-switch-pro-annual-to-premium";
const proAnnualMessages = items.monthlyMessages({ includedUsage: 500 });
const proAnnual = products.proAnnual({
id: "pro-annual",
items: [proAnnualMessages],
});
const premiumMessages = items.monthlyMessages({ includedUsage: 1000 });
const premium = products.premium({
id: "premium",
items: [premiumMessages],
});
const { autumnV1, ctx } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [proAnnual, premium] }),
],
actions: [s.billing.attach({ productId: proAnnual.id })],
});
// 1. Preview switch to premium monthly
// At start of cycle: credit for full annual ($200) exceeds premium monthly ($50)
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: premium.id,
});
expect(preview.total).toBe(0);
// 2. Attach premium (immediate because interval differs: annual -> monthly)
await autumnV1.billing.attach({
customer_id: customerId,
product_id: premium.id,
redirect_mode: "if_required",
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Premium should be active, pro annual should be gone (immediate switch)
await expectCustomerProducts({
customer,
active: [premium.id],
notPresent: [proAnnual.id],
});
// Verify messages feature has premium's balance
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: 1000,
balance: 1000,
usage: 0,
});
// Verify invoices: proAnnual ($200) + switch invoice ($0, credit exceeds charge)
await expectCustomerInvoiceCorrect({
customer,
count: 2,
latestTotal: 0,
});
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});

View File

@@ -344,13 +344,15 @@ test.concurrent(`${chalk.yellowBright("scheduled-switch-entities-cross 3: entity
/**
* Scenario:
* - Entity 1: Premium Annual → Pro (scheduled)
* - Entity 2: Premium Monthly → Pro (scheduled)
* - Entity 1: Premium Annual → Pro (immediate, interval change)
* - Entity 2: Premium Monthly → Pro (scheduled, same interval)
* - Advance 1 month (monthly cycle ends)
* - Upgrade entity 2 back to premium
*
* Expected Result:
* - After cycle: Entity 1 still on annual (hasn't ended yet), Entity 2 on pro
* - Entity 1 immediately switches to pro (annual→monthly = different interval = immediate)
* - Entity 2 has premium canceling, pro scheduled (same interval = end_of_cycle)
* - After cycle: Entity 1 on pro (renewed), Entity 2 on pro (scheduled switch completed)
* - After upgrade: Entity 2 on premium
*/
test.concurrent(`${chalk.yellowBright("scheduled-switch-entities-cross 4: entity 1 premiumAnnual to pro, entity 2 premium to pro, advance cycle, upgrade entity 2 to premium")}`, async () => {
@@ -385,28 +387,27 @@ test.concurrent(`${chalk.yellowBright("scheduled-switch-entities-cross 4: entity
actions: [
s.billing.attach({ productId: premiumAnnual.id, entityIndex: 0 }),
s.billing.attach({ productId: premium.id, entityIndex: 1 }),
s.billing.attach({ productId: pro.id, entityIndex: 0 }), // Downgrade entity 1 (annual)
s.billing.attach({ productId: pro.id, entityIndex: 1 }), // Downgrade entity 2 (monthly)
s.billing.attach({ productId: pro.id, entityIndex: 0 }), // Entity 1: immediate (interval change)
s.billing.attach({ productId: pro.id, entityIndex: 1 }), // Entity 2: scheduled (same interval)
s.advanceToNextInvoice(), // Advance 1 month
],
});
// Verify entity 1: still on annual (annual hasn't ended)
// Entity 1 already switched to pro immediately (annual→monthly = different interval)
const entity1Before = await autumnV1.entities.get<ApiEntityV0>(
customerId,
entities[0].id,
);
// Entity 1's annual subscription should still be active with pro scheduled
await expectProductCanceling({
customer: entity1Before,
productId: premiumAnnual.id,
});
await expectProductScheduled({
await expectProductActive({
customer: entity1Before,
productId: pro.id,
});
await expectProductNotPresent({
customer: entity1Before,
productId: premiumAnnual.id,
});
// Verify entity 2: now on pro (monthly cycle completed)
// Entity 2: now on pro (monthly cycle completed, scheduled switch took effect)
const entity2Before = await autumnV1.entities.get<ApiEntityV0>(
customerId,
entities[1].id,

View File

@@ -11,6 +11,16 @@ import { BillingType } from "../../../models/productModels/priceModels/priceEnum
import type { Price } from "../../../models/productModels/priceModels/priceModels";
import { getBillingType } from "../priceUtils";
/** Returns the billing interval of the base (fixed) price for a set of prices, or null if none found. */
export const getProductBaseInterval = ({ prices }: { prices: Price[] }) => {
const basePrice = prices.find(isFixedPrice);
if (!basePrice) return null;
return {
interval: basePrice.config.interval as BillingInterval,
intervalCount: basePrice.config.interval_count ?? 1,
};
};
export const isOneOffPrice = (
price: Price,
): price is Price & {