Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { msToSeconds, orgToReturnUrl } from "@autumn/shared";
import type Stripe from "stripe";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { buildStripeCheckoutSessionItems } from "@/internal/billing/v2/providers/stripe/utils/checkoutSessions/buildStripeCheckoutSessionItems";
import { stripeDiscountsToParams } from "@/internal/billing/v2/providers/stripe/utils/discounts/stripeDiscountsToParams";
import { stripeDiscountsToCheckoutParams } from "@/internal/billing/v2/providers/stripe/utils/discounts/stripeDiscountsToParams";

export const buildStripeCheckoutSessionAction = ({
ctx,
Expand Down Expand Up @@ -64,7 +64,7 @@ export const buildStripeCheckoutSessionAction = ({

// 6. Build discounts for checkout session
const discounts = stripeDiscounts?.length
? stripeDiscountsToParams({ stripeDiscounts })
? stripeDiscountsToCheckoutParams({ stripeDiscounts })
: undefined;

// 7. Build params (only variable params - static params added in execute)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,35 @@
import type { StripeDiscountWithCoupon } from "@autumn/shared";

/**
* Maps internal discount objects to Stripe API `discounts` param format.
* Uses { promotion_code: id } when the discount originates from a promo code,
* otherwise uses { coupon: id } for direct coupon references.
* Maps internal discount objects to Stripe API `discounts` param format for subscription updates.
* Uses { discount: id } for existing discounts (preserving original start/end),
* { promotion_code: id } for promo-code-based new discounts,
* and { coupon: id } for new coupon-based discounts.
*/
export const stripeDiscountsToParams = ({
stripeDiscounts,
}: {
stripeDiscounts: StripeDiscountWithCoupon[];
}): (
| { discount: string }
| { coupon: string }
| { promotion_code: string }
)[] => {
return stripeDiscounts.map((d) => {
if (d.id) return { discount: d.id };
if (d.promotionCodeId) return { promotion_code: d.promotionCodeId };
return { coupon: d.source.coupon.id };
});
};

/**
* Maps discount objects to Stripe checkout session `discounts` param format.
* Checkout sessions only accept { coupon } or { promotion_code } — not { discount }.
*/
export const stripeDiscountsToCheckoutParams = ({
stripeDiscounts,
}: {
stripeDiscounts: StripeDiscountWithCoupon[];
}): ({ coupon: string } | { promotion_code: string })[] => {
return stripeDiscounts.map((d) =>
d.promotionCodeId
Expand Down
44 changes: 2 additions & 42 deletions server/tests/_temp/temp.test.ts
Original file line number Diff line number Diff line change
@@ -1,46 +1,6 @@
import { test } from "bun:test";
import {
FreeTrialDuration,
type UpdateSubscriptionV1Params,
} from "@autumn/shared";
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";

const customerId = "temp-test";

test.concurrent(`${chalk.yellowBright("temp: rest update then rpc inverse update returns product to baseline")}`, async () => {
const proProd = products.pro({
id: "pro",
items: [items.monthlyCredits({ includedUsage: 100 })],
});
const customerId = "temp";

const { autumnV1, autumnV2_1 } = await initScenario({
customerId,
actions: [],
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [proProd] }),
],
});

const result = await autumnV1.billing.attach({
customer_id: customerId,
product_id: proProd.id,
free_trial: {
length: 14,
duration: FreeTrialDuration.Day,
},
});

console.log(result);

const updateResult =
await autumnV2_1.subscriptions.update<UpdateSubscriptionV1Params>({
customer_id: customerId,
plan_id: proProd.id,
});
console.log(updateResult);
test(`${chalk.yellowBright("temp: placeholder")}`, async () => {
// Placeholder test
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
/**
* Immediate Switch Discount Preservation Tests
*
* Tests that discounts are preserved (same ID, same duration/end date)
* when upgrading from one paid product to another.
* Users should NOT get extra discount duration from plan changes.
*/

import { expect, test } from "bun:test";
import {
applySubscriptionDiscount,
createPercentCoupon,
getStripeSubscription,
} from "@tests/integration/billing/utils/discounts/discountTestUtils";
import { items } from "@tests/utils/fixtures/items";
import { products } from "@tests/utils/fixtures/products";
import { advanceTestClock } from "@tests/utils/stripeUtils";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";

// ═══════════════════════════════════════════════════════════════════════════════
// TEST 1: Upgrade pro → premium preserves discount identity and duration
// ═══════════════════════════════════════════════════════════════════════════════

/**
* Scenario:
* - Customer has pro ($20/mo) with 2-month repeating 20% off coupon
* - Advance 2 weeks (mid-cycle)
* - Upgrade to premium ($50/mo) — immediate switch
*
* Expected:
* - Discount ID unchanged (same di_xxx)
* - Discount end timestamp unchanged (duration not reset)
*/
test.concurrent(`${chalk.yellowBright("immediate-switch-discounts 1: upgrade pro -> premium preserves discount identity and duration")}`, async () => {
const customerId = "imm-switch-discount-upgrade";

const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 500 })],
});

const premium = products.premium({
id: "premium",
items: [items.monthlyMessages({ includedUsage: 1000 })],
});

const { autumnV1, testClockId, ctx } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, premium] }),
],
actions: [s.billing.attach({ productId: pro.id })],
});

// Apply 2-month repeating 20% off coupon
const { stripeCli, subscription: sub } = await getStripeSubscription({
customerId,
});

const coupon = await createPercentCoupon({
stripeCli,
percentOff: 20,
duration: "repeating",
durationInMonths: 2,
});

await applySubscriptionDiscount({
stripeCli,
subscriptionId: sub.id,
couponIds: [coupon.id],
});

// Record discount before upgrade
const subWithDiscount = await stripeCli.subscriptions.retrieve(sub.id, {
expand: ["discounts.source.coupon"],
});
expect(subWithDiscount.discounts?.length).toBeGreaterThanOrEqual(1);

const discountBefore = subWithDiscount.discounts![0];
const discountIdBefore =
typeof discountBefore !== "string" ? discountBefore.id : null;
const discountEndBefore =
typeof discountBefore !== "string" ? discountBefore.end : null;
expect(discountIdBefore).not.toBeNull();
expect(discountEndBefore).not.toBeNull();

// Advance 2 weeks
await advanceTestClock({
stripeCli: ctx.stripeCli,
testClockId: testClockId!,
numberOfDays: 14,
});

// Upgrade to premium (immediate switch)
await autumnV1.billing.attach({
customer_id: customerId,
product_id: premium.id,
redirect_mode: "if_required",
});

// Verify discount is preserved
const { subscription: subAfterUpgrade } = await getStripeSubscription({
customerId,
});
const subAfter = await stripeCli.subscriptions.retrieve(subAfterUpgrade.id, {
expand: ["discounts.source.coupon"],
});
expect(subAfter.discounts?.length).toBeGreaterThanOrEqual(1);

const discountAfter = subAfter.discounts![0];
const discountIdAfter =
typeof discountAfter !== "string" ? discountAfter.id : null;
const discountEndAfter =
typeof discountAfter !== "string" ? discountAfter.end : null;

// Discount ID must be the same (not re-created)
expect(discountIdAfter).toBe(discountIdBefore);

// Discount end must be the same (duration not reset)
expect(discountEndAfter).toBe(discountEndBefore);
});

// ═══════════════════════════════════════════════════════════════════════════════
// TEST 2: Upgrade pro → premium → ultra preserves discount through multiple switches
// ═══════════════════════════════════════════════════════════════════════════════

/**
* Scenario:
* - Customer has pro ($20/mo) with 3-month repeating 20% off coupon
* - Advance 2 weeks
* - Upgrade to premium ($50/mo)
* - Upgrade to ultra ($200/mo)
*
* Expected:
* - Discount ID unchanged through both upgrades
* - Discount end timestamp unchanged (duration not reset)
*/
test.concurrent(`${chalk.yellowBright("immediate-switch-discounts 2: discount preserved through multiple upgrades")}`, async () => {
const customerId = "imm-switch-discount-multi-upgrade";

const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 500 })],
});

const premium = products.premium({
id: "premium",
items: [items.monthlyMessages({ includedUsage: 1000 })],
});

const ultra = products.ultra({
id: "ultra",
items: [items.monthlyMessages({ includedUsage: 5000 })],
});

const { autumnV1, testClockId, ctx } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, premium, ultra] }),
],
actions: [s.billing.attach({ productId: pro.id })],
});

// Apply 3-month repeating 20% off coupon
const { stripeCli, subscription: sub } = await getStripeSubscription({
customerId,
});

const coupon = await createPercentCoupon({
stripeCli,
percentOff: 20,
duration: "repeating",
durationInMonths: 3,
});

await applySubscriptionDiscount({
stripeCli,
subscriptionId: sub.id,
couponIds: [coupon.id],
});

// Record original discount
const subWithDiscount = await stripeCli.subscriptions.retrieve(sub.id, {
expand: ["discounts.source.coupon"],
});
const discountBefore = subWithDiscount.discounts![0];
const discountIdBefore =
typeof discountBefore !== "string" ? discountBefore.id : null;
const discountEndBefore =
typeof discountBefore !== "string" ? discountBefore.end : null;
expect(discountIdBefore).not.toBeNull();
expect(discountEndBefore).not.toBeNull();

// Advance 2 weeks
await advanceTestClock({
stripeCli: ctx.stripeCli,
testClockId: testClockId!,
numberOfDays: 14,
});

// Upgrade pro → premium
await autumnV1.billing.attach({
customer_id: customerId,
product_id: premium.id,
redirect_mode: "if_required",
});

// Upgrade premium → ultra
await autumnV1.billing.attach({
customer_id: customerId,
product_id: ultra.id,
redirect_mode: "if_required",
});

// Verify discount is preserved after both upgrades
const { subscription: subAfter } = await getStripeSubscription({
customerId,
});
const subAfterExpanded = await stripeCli.subscriptions.retrieve(subAfter.id, {
expand: ["discounts.source.coupon"],
});
expect(subAfterExpanded.discounts?.length).toBeGreaterThanOrEqual(1);

const discountAfter = subAfterExpanded.discounts![0];
const discountIdAfter =
typeof discountAfter !== "string" ? discountAfter.id : null;
const discountEndAfter =
typeof discountAfter !== "string" ? discountAfter.end : null;

// Discount ID must be the same through both upgrades
expect(discountIdAfter).toBe(discountIdBefore);

// Discount end must be the same (duration not reset)
expect(discountEndAfter).toBe(discountEndBefore);
});
Loading