Merge branch 'charlie/attach-v2' of https://github.com/useautumn/autumn into charlie/attach-v2

This commit is contained in:
John Yeo
2026-02-13 17:15:32 +00:00
63 changed files with 2430 additions and 709 deletions

View File

@@ -0,0 +1,2 @@
export { resolveCoupon } from "./resolveCoupon";
export { resolvePromotionCode } from "./resolvePromotionCode";

View File

@@ -0,0 +1,33 @@
import type { StripeDiscountWithCoupon } from "@autumn/shared";
import { RecaseError } from "@autumn/shared";
import type Stripe from "stripe";
/**
* Retrieves and validates a Stripe coupon by its ID.
* Returns a StripeDiscountWithCoupon if valid, throws RecaseError if invalid or not found.
*/
export const resolveCoupon = async ({
stripeCli,
couponId,
}: {
stripeCli: Stripe;
couponId: string;
}): Promise<StripeDiscountWithCoupon> => {
try {
const coupon = await stripeCli.coupons.retrieve(couponId);
if (!coupon.valid) {
throw new RecaseError({
message: `Coupon "${couponId}" is no longer valid`,
});
}
return { source: { coupon } };
} catch (error) {
if (error instanceof RecaseError) throw error;
throw new RecaseError({
message: `Invalid coupon ID: "${couponId}"`,
});
}
};

View File

@@ -0,0 +1,57 @@
import type { StripeDiscountWithCoupon } from "@autumn/shared";
import { RecaseError } from "@autumn/shared";
import type Stripe from "stripe";
/**
* Resolves a human-readable promotion code string to a StripeDiscountWithCoupon.
* Validates that the promotion code exists, is active, and its coupon is valid.
* Stores the promotion code ID for proper attribution in checkout sessions.
*/
export const resolvePromotionCode = async ({
stripeCli,
code,
}: {
stripeCli: Stripe;
code: string;
}): Promise<StripeDiscountWithCoupon> => {
try {
const promos = await stripeCli.promotionCodes.list({
code,
active: true,
limit: 1,
expand: ["data.promotion.coupon"],
});
if (promos.data.length === 0) {
throw new RecaseError({
message: `Promotion code not found or inactive: "${code}"`,
});
}
const promo = promos.data[0];
const couponRaw = promo.promotion.coupon;
if (!couponRaw || typeof couponRaw === "string") {
throw new RecaseError({
message: `Could not resolve coupon for promotion code "${code}"`,
});
}
if (!couponRaw.valid) {
throw new RecaseError({
message: `Coupon for promotion code "${code}" is no longer valid`,
});
}
return {
source: { coupon: couponRaw },
promotionCodeId: promo.id,
};
} catch (error) {
if (error instanceof RecaseError) throw error;
throw new RecaseError({
message: `Invalid promotion code: "${code}"`,
});
}
};

View File

@@ -88,7 +88,7 @@ const checkCurStripePrice = async ({
} else {
stripePrepaidPriceV2 = await getStripePrice({
stripeClient: stripeCli,
stripePriceId: config.stripe_prepaid_price_v2_id ?? undefined,
stripePriceId: config.stripe_prepaid_price_v2_id,
});
}

View File

@@ -30,7 +30,7 @@ export type StripeCustomerExpandedDiscount = Omit<Stripe.Discount, "source"> & {
/**
* Stripe subscription with discounts expanded.
* Compatible type for setupStripeDiscountsForBilling.
* Compatible type for extractStripeDiscounts / fetchStripeDiscountsForBilling.
*/
export type StripeSubscriptionWithDiscounts = Stripe.Subscription & {
discounts: StripeExpandedDiscount[];
@@ -38,7 +38,7 @@ export type StripeSubscriptionWithDiscounts = Stripe.Subscription & {
/**
* Stripe customer with discount expanded.
* Compatible type for setupStripeDiscountsForBilling.
* Compatible type for extractStripeDiscounts / fetchStripeDiscountsForBilling.
*/
export type StripeCustomerWithDiscount = Stripe.Customer & {
discount: StripeCustomerExpandedDiscount | null;

View File

@@ -5,7 +5,7 @@ import type {
UpdateCustomerEntitlement,
} from "@autumn/shared";
import type { StripeWebhookContext } from "@/external/stripe/webhookMiddlewares/stripeWebhookContext";
import { setupStripeDiscountsForBilling } from "@/internal/billing/v2/providers/stripe/setup/setupStripeDiscountsForBilling";
import { extractStripeDiscounts } from "@/internal/billing/v2/providers/stripe/setup/fetchStripeDiscountsForBilling";
import { applyStripeDiscountsToLineItems } from "@/internal/billing/v2/providers/stripe/utils/discounts/applyStripeDiscountsToLineItems";
import { customerProductToArrearLineItems } from "@/internal/billing/v2/utils/lineItems/customerProductToArrearLineItems";
import {
@@ -67,7 +67,7 @@ export const eventContextToArrearLineItems = ({
}
// Apply discounts to line items
const discounts = setupStripeDiscountsForBilling({
const discounts = extractStripeDiscounts({
stripeSubscription: eventContext.stripeSubscription,
stripeCustomer: eventContext.stripeCustomer,
});

View File

@@ -72,6 +72,7 @@ export const setupAttachBillingContext = async ({
product: attachProduct,
targetCustomerProduct: currentCustomerProduct,
contextOverride,
paramDiscounts: params.discounts,
});
const featureQuantities = setupFeatureQuantitiesContext({

View File

@@ -5,9 +5,9 @@ import {
stripeSubscriptionToScheduleId,
} from "@/external/stripe/subscriptions";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { extractStripeDiscounts } from "@/internal/billing/v2/providers/stripe/setup/fetchStripeDiscountsForBilling";
import { fetchStripeSubscriptionForBilling } from "@/internal/billing/v2/providers/stripe/setup/fetchStripeSubscriptionForBilling";
import { fetchStripeSubscriptionScheduleForBilling } from "@/internal/billing/v2/providers/stripe/setup/fetchStripeSubscriptionScheduleForBilling";
import { setupStripeDiscountsForBilling } from "@/internal/billing/v2/providers/stripe/setup/setupStripeDiscountsForBilling";
import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams";
export const attachParamsToStripeBillingContext = async ({
@@ -37,7 +37,7 @@ export const attachParamsToStripeBillingContext = async ({
const stripeCustomer = attachParams.stripeCus as StripeCustomerWithDiscount;
const stripeDiscounts = setupStripeDiscountsForBilling({
const stripeDiscounts = extractStripeDiscounts({
stripeSubscription,
stripeCustomer,
});

View File

@@ -7,6 +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";
export const buildStripeCheckoutSessionAction = ({
ctx,
@@ -18,7 +19,7 @@ export const buildStripeCheckoutSessionAction = ({
autumnBillingPlan: AutumnBillingPlan;
}): StripeCheckoutSessionAction => {
const { org, env } = ctx;
const { trialContext, stripeCustomer } = billingContext;
const { trialContext, stripeCustomer, stripeDiscounts } = billingContext;
// 1. Get recurring and one-off items (recurring filtered to largest interval)
const { recurringLineItems, oneOffLineItems } =
@@ -61,13 +62,19 @@ export const buildStripeCheckoutSessionAction = ({
}
: undefined;
// 6. Build params (only variable params - static params added in execute)
// 6. Build discounts for checkout session
const discounts = stripeDiscounts?.length
? stripeDiscountsToParams({ stripeDiscounts })
: undefined;
// 7. Build params (only variable params - static params added in execute)
const params: Stripe.Checkout.SessionCreateParams = {
customer: stripeCustomer.id,
mode,
line_items: lineItems,
subscription_data: subscriptionData,
success_url: orgToReturnUrl({ org, env }),
discounts,
};
return { type: "create", params };

View File

@@ -40,12 +40,16 @@ export const executeStripeCheckoutSessionAction = async ({
});
// 2. Build full checkout params (merge variable + static params)
// Stripe doesn't allow both `discounts` and `allow_promotion_codes` simultaneously
const hasPreAppliedDiscounts =
!!checkoutSessionAction.params.discounts?.length;
const fullParams: Stripe.Checkout.SessionCreateParams = {
...checkoutSessionAction.params,
// Static params
currency: orgToCurrency({ org }),
allow_promotion_codes: true,
allow_promotion_codes: hasPreAppliedDiscounts ? undefined : true,
saved_payment_method_options: { payment_method_save: "enabled" },
invoice_creation:
checkoutSessionAction.params.mode === "payment"

View File

@@ -0,0 +1,84 @@
import type { AttachDiscount, StripeDiscountWithCoupon } from "@autumn/shared";
import { createStripeCli } from "@/external/connect/createStripeCli";
import type {
StripeCustomerWithDiscount,
StripeSubscriptionWithDiscounts,
} from "@/external/stripe/subscriptions";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { resolveParamDiscounts } from "../utils/discounts/resolveParamDiscounts";
import { subToDiscounts } from "../utils/discounts/subToDiscounts";
/**
* Extracts discounts from already-fetched Stripe subscription or customer.
* Subscription discounts take priority over customer discounts.
*
* Both subscription and customer discounts use the `source.coupon` structure
* introduced in Stripe API version 2025-09-30.clover.
*
* @see https://docs.stripe.com/changelog/clover/2025-09-30/add-discount-source-property
* @see https://docs.stripe.com/api/discounts/object
*/
export const extractStripeDiscounts = ({
stripeSubscription,
stripeCustomer,
}: {
stripeSubscription?: StripeSubscriptionWithDiscounts;
stripeCustomer: StripeCustomerWithDiscount;
}): StripeDiscountWithCoupon[] => {
const subscriptionDiscounts = subToDiscounts({ sub: stripeSubscription });
if (subscriptionDiscounts.length > 0) {
return subscriptionDiscounts;
}
const customerDiscount = stripeCustomer.discount;
if (!customerDiscount) return [];
const coupon = customerDiscount.source?.coupon;
if (!coupon || typeof coupon === "string") return [];
// Customer discount already has source.coupon structure, return as-is
return [customerDiscount as StripeDiscountWithCoupon];
};
/**
* Fetches discounts for billing, combining existing Stripe discounts with optional param discounts.
* Resolves param discounts via Stripe API and merges with existing subscription/customer discounts.
* Deduplicates by coupon ID.
*/
export const fetchStripeDiscountsForBilling = async ({
ctx,
stripeSubscription,
stripeCustomer,
paramDiscounts,
}: {
ctx: AutumnContext;
stripeSubscription?: StripeSubscriptionWithDiscounts;
stripeCustomer: StripeCustomerWithDiscount;
paramDiscounts?: AttachDiscount[];
}): Promise<StripeDiscountWithCoupon[]> => {
const existingDiscounts = extractStripeDiscounts({
stripeSubscription,
stripeCustomer,
});
if (!paramDiscounts?.length) {
return existingDiscounts;
}
const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env });
const resolvedParamDiscounts = await resolveParamDiscounts({
stripeCli,
discounts: paramDiscounts,
});
// Merge with existing discounts, deduplicating by coupon ID
const existingCouponIds = new Set(
existingDiscounts.map((d) => d.source.coupon.id),
);
const newDiscounts = resolvedParamDiscounts.filter(
(d) => !existingCouponIds.has(d.source.coupon.id),
);
return [...existingDiscounts, ...newDiscounts];
};

View File

@@ -1,4 +1,5 @@
import type {
AttachDiscount,
BillingContextOverride,
FullCusProduct,
FullCustomer,
@@ -6,9 +7,9 @@ import type {
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { fetchStripeCustomerForBilling } from "./fetchStripeCustomerForBilling";
import { fetchStripeDiscountsForBilling } from "./fetchStripeDiscountsForBilling";
import { fetchStripeSubscriptionForBilling } from "./fetchStripeSubscriptionForBilling";
import { fetchStripeSubscriptionScheduleForBilling } from "./fetchStripeSubscriptionScheduleForBilling";
import { setupStripeDiscountsForBilling } from "./setupStripeDiscountsForBilling";
export const setupStripeBillingContext = async ({
ctx,
@@ -16,12 +17,14 @@ export const setupStripeBillingContext = async ({
product,
targetCustomerProduct,
contextOverride = {},
paramDiscounts,
}: {
ctx: AutumnContext;
fullCustomer: FullCustomer;
product?: Product;
targetCustomerProduct?: FullCusProduct;
contextOverride?: BillingContextOverride;
paramDiscounts?: AttachDiscount[];
}) => {
const { stripeBillingContext } = contextOverride;
@@ -57,9 +60,11 @@ export const setupStripeBillingContext = async ({
fullCus: fullCustomer,
});
const stripeDiscounts = setupStripeDiscountsForBilling({
const stripeDiscounts = await fetchStripeDiscountsForBilling({
ctx,
stripeSubscription,
stripeCustomer,
paramDiscounts,
});
return {

View File

@@ -1,39 +0,0 @@
import type { StripeDiscountWithCoupon } from "@autumn/shared";
import type {
StripeCustomerWithDiscount,
StripeSubscriptionWithDiscounts,
} from "@/external/stripe/subscriptions";
import { subToDiscounts } from "../utils/discounts/subToDiscounts";
/**
* Extracts discounts from already-fetched Stripe subscription or customer.
* Subscription discounts take priority over customer discounts.
*
* Both subscription and customer discounts use the `source.coupon` structure
* introduced in Stripe API version 2025-09-30.clover.
*
* @see https://docs.stripe.com/changelog/clover/2025-09-30/add-discount-source-property
* @see https://docs.stripe.com/api/discounts/object
*/
export const setupStripeDiscountsForBilling = ({
stripeSubscription,
stripeCustomer,
}: {
stripeSubscription?: StripeSubscriptionWithDiscounts;
stripeCustomer: StripeCustomerWithDiscount;
}): StripeDiscountWithCoupon[] => {
const subscriptionDiscounts = subToDiscounts({ sub: stripeSubscription });
if (subscriptionDiscounts.length > 0) {
return subscriptionDiscounts;
}
const customerDiscount = stripeCustomer.discount;
if (!customerDiscount) return [];
const coupon = customerDiscount.source?.coupon;
if (!coupon || typeof coupon === "string") return [];
// Customer discount already has source.coupon structure, return as-is
return [customerDiscount as StripeDiscountWithCoupon];
};

View File

@@ -0,0 +1,36 @@
import type { AttachDiscount, StripeDiscountWithCoupon } from "@autumn/shared";
import type Stripe from "stripe";
import { resolveCoupon, resolvePromotionCode } from "@/external/stripe/coupons";
/**
* Resolves `discounts` param entries into validated Stripe coupon objects.
* Accepts coupon IDs (passed directly) and human-readable promo code strings (resolved via Stripe API).
*/
export const resolveParamDiscounts = async ({
stripeCli,
discounts,
}: {
stripeCli: Stripe;
discounts: AttachDiscount[];
}): Promise<StripeDiscountWithCoupon[]> => {
const resolved = await Promise.all(
discounts.map((discount) => {
if ("reward_id" in discount) {
return resolveCoupon({ stripeCli, couponId: discount.reward_id });
}
return resolvePromotionCode({
stripeCli,
code: discount.promotion_code,
});
}),
);
// Deduplicate by coupon ID
const seen = new Set<string>();
return resolved.filter((d) => {
const couponId = d.source.coupon.id;
if (seen.has(couponId)) return false;
seen.add(couponId);
return true;
});
};

View File

@@ -0,0 +1,18 @@
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.
*/
export const stripeDiscountsToParams = ({
stripeDiscounts,
}: {
stripeDiscounts: StripeDiscountWithCoupon[];
}): ({ coupon: string } | { promotion_code: string })[] => {
return stripeDiscounts.map((d) =>
d.promotionCodeId
? { promotion_code: d.promotionCodeId }
: { coupon: d.source.coupon.id },
);
};

View File

@@ -7,6 +7,7 @@ import {
import type Stripe from "stripe";
import { logPhase } from "@/external/stripe/subscriptionSchedules/utils/logStripeSchedulePhaseUtils";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { stripeDiscountsToParams } from "@/internal/billing/v2/providers/stripe/utils/discounts/stripeDiscountsToParams";
import { customerProductToStripeItemSpecs } from "@/internal/billing/v2/providers/stripe/utils/subscriptionItems/customerProductToStripeItemSpecs";
import { isCustomerProductActiveDuringPeriod } from "@/internal/billing/v2/providers/stripe/utils/subscriptionSchedules/isCustomerProductActiveAtEpochMs";
import { buildTransitionPoints } from "./buildTransitionPoints";
@@ -119,6 +120,12 @@ export const buildStripePhasesUpdate = ({
});
}
const discounts = billingContext.stripeDiscounts?.length
? stripeDiscountsToParams({
stripeDiscounts: billingContext.stripeDiscounts,
})
: undefined;
let startMs = nowMs;
const phases: Stripe.SubscriptionScheduleUpdateParams.Phase[] = [];
@@ -167,6 +174,7 @@ export const buildStripePhasesUpdate = ({
start_date: msToSeconds(startMs),
end_date: endMs ? msToSeconds(endMs) : undefined,
trial_end: computePhaseTrialEndsAt(),
discounts,
};
// Log phase details

View File

@@ -1,7 +1,8 @@
import type { BillingContext } from "@autumn/shared";
import { msToSeconds } from "@autumn/shared";
import type Stripe from "stripe";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import type { BillingContext } from "@autumn/shared";
import { stripeDiscountsToParams } from "@/internal/billing/v2/providers/stripe/utils/discounts/stripeDiscountsToParams";
export const buildStripeSubscriptionCreateAction = ({
ctx,
@@ -16,7 +17,8 @@ export const buildStripeSubscriptionCreateAction = ({
addInvoiceItems: Stripe.SubscriptionCreateParams.AddInvoiceItem[];
subscriptionCancelAt?: number;
}) => {
const { stripeCustomer, paymentMethod, trialContext } = billingContext;
const { stripeCustomer, paymentMethod, trialContext, stripeDiscounts } =
billingContext;
const trialEndsAt = trialContext?.trialEndsAt;
@@ -44,6 +46,10 @@ export const buildStripeSubscriptionCreateAction = ({
cancel_at: subscriptionCancelAt,
...(stripeDiscounts?.length && {
discounts: stripeDiscountsToParams({ stripeDiscounts }),
}),
...(freeTrialNoCardRequired && {
trial_settings: {
end_behavior: {

View File

@@ -1,12 +1,13 @@
import type {
BillingContext,
StripeSubscriptionAction,
StripeSubscriptionScheduleAction,
} from "@autumn/shared";
import { msToSeconds } from "@shared/utils/common/unixUtils";
import { notNullish } from "@shared/utils/utils";
import type Stripe from "stripe";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import type { BillingContext } from "@autumn/shared";
import type {
StripeSubscriptionAction,
StripeSubscriptionScheduleAction,
} from "@autumn/shared";
import { stripeDiscountsToParams } from "@/internal/billing/v2/providers/stripe/utils/discounts/stripeDiscountsToParams";
export const buildStripeSubscriptionUpdateAction = ({
// biome-ignore lint/correctness/noUnusedFunctionParameters: might be used in the future
@@ -22,7 +23,8 @@ export const buildStripeSubscriptionUpdateAction = ({
stripeSubscriptionScheduleAction?: StripeSubscriptionScheduleAction;
subscriptionCancelAt?: number;
}): StripeSubscriptionAction | undefined => {
const { stripeSubscription, trialContext, cancelAction } = billingContext;
const { stripeSubscription, trialContext, cancelAction, stripeDiscounts } =
billingContext;
if (!stripeSubscription) {
throw new Error(
@@ -72,11 +74,18 @@ export const buildStripeSubscriptionUpdateAction = ({
? subscriptionCancelAt
: undefined,
proration_behavior: "none",
...(stripeDiscounts?.length && {
discounts: stripeDiscountsToParams({ stripeDiscounts }),
}),
};
const hasNoUpdates = [params.items, params.trial_end, params.cancel_at].every(
(field) => field === undefined,
);
const hasNoUpdates = [
params.items,
params.trial_end,
params.cancel_at,
params.discounts,
].every((field) => field === undefined);
if (hasNoUpdates) {
return undefined;

View File

@@ -0,0 +1,497 @@
/**
* Integration tests for attaching products with discounts param.
*
* Tests basic discount scenarios:
* - Percent-off and amount-off rewards on new subscriptions
* - Promotion code resolution
* - Multiple rewards stacking
* - Duplicate reward deduplication
* - Upgrade with discount
* - Preview accuracy with discounts
*/
import { expect, test } from "bun:test";
import type { ApiCustomerV3 } from "@autumn/shared";
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect.js";
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect.js";
import { expectCustomerProducts } from "@tests/integration/billing/utils/expectCustomerProductCorrect.js";
import { TestFeature } from "@tests/setup/v2Features.js";
import { items } from "@tests/utils/fixtures/items.js";
import { products } from "@tests/utils/fixtures/products.js";
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
import chalk from "chalk";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import {
createAmountCoupon,
createPercentCoupon,
createPromotionCode,
getStripeSubscription,
} from "../../utils/discounts/discountTestUtils.js";
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 1: Free to Pro with percent-off reward
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer on free product
* - Create 20% off coupon in Stripe
* - Attach pro ($20/mo) with discount param
*
* Expected:
* - Pro active, free removed
* - Invoice = $20 * 0.8 = $16
*/
test.concurrent(`${chalk.yellowBright("attach-discount 1: free to pro with percent-off reward")}`, async () => {
const customerId = "att-disc-pct-off";
const free = products.base({
id: "free",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 500 })],
});
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [free, pro] }),
],
actions: [s.billing.attach({ productId: free.id })],
});
const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env });
const coupon = await createPercentCoupon({ stripeCli, percentOff: 20 });
await autumnV1.billing.attach({
customer_id: customerId,
product_id: pro.id,
discounts: [{ reward_id: coupon.id }],
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerProducts({
customer,
active: [pro.id],
notPresent: [free.id],
});
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: 500,
balance: 500,
usage: 0,
});
await expectCustomerInvoiceCorrect({
customer,
count: 1,
latestTotal: 16,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 2: Free to Pro with amount-off reward
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer on free product
* - Create $5 off coupon in Stripe
* - Attach pro ($20/mo) with discount param
*
* Expected:
* - Pro active, free removed
* - Invoice = $20 - $5 = $15
*/
test.concurrent(`${chalk.yellowBright("attach-discount 2: free to pro with amount-off reward")}`, async () => {
const customerId = "att-disc-amt-off";
const free = products.base({
id: "free",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 500 })],
});
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [free, pro] }),
],
actions: [s.billing.attach({ productId: free.id })],
});
const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env });
const coupon = await createAmountCoupon({ stripeCli, amountOffCents: 500 });
await autumnV1.billing.attach({
customer_id: customerId,
product_id: pro.id,
discounts: [{ reward_id: coupon.id }],
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerProducts({
customer,
active: [pro.id],
notPresent: [free.id],
});
await expectCustomerInvoiceCorrect({
customer,
count: 1,
latestTotal: 15,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 3: Free to Pro with promotion code
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer on free product
* - Create 25% coupon + promotion code in Stripe
* - Attach pro ($20/mo) with promotion_code param
*
* Expected:
* - Pro active
* - Invoice = $20 * 0.75 = $15
*/
test.concurrent(`${chalk.yellowBright("attach-discount 3: free to pro with promotion code")}`, async () => {
const customerId = "att-disc-promo";
const free = products.base({
id: "free",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 500 })],
});
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [free, pro] }),
],
actions: [s.billing.attach({ productId: free.id })],
});
const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env });
const coupon = await createPercentCoupon({ stripeCli, percentOff: 25 });
const promoCode = await createPromotionCode({
stripeCli,
coupon,
code: `SAVE25-${customerId}`,
});
// Use the human-readable code string (not the promo code ID)
await autumnV1.billing.attach({
customer_id: customerId,
product_id: pro.id,
discounts: [{ promotion_code: promoCode.code }],
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerProducts({
customer,
active: [pro.id],
notPresent: [free.id],
});
await expectCustomerInvoiceCorrect({
customer,
count: 1,
latestTotal: 15,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 4: Multiple rewards stack on new subscription
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer on free product
* - Create 20% off + $2 off coupons
* - Attach pro ($20/mo) with both discounts
*
* Expected:
* - Percent applied first: $20 * 0.8 = $16
* - Then amount: $16 - $2 = $14
* - Invoice = $14
*/
test.concurrent(`${chalk.yellowBright("attach-discount 4: multiple rewards stack on new subscription")}`, async () => {
const customerId = "att-disc-multi";
const free = products.base({
id: "free",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 500 })],
});
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [free, pro] }),
],
actions: [s.billing.attach({ productId: free.id })],
});
const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env });
const pctCoupon = await createPercentCoupon({ stripeCli, percentOff: 20 });
const amtCoupon = await createAmountCoupon({
stripeCli,
amountOffCents: 200,
});
await autumnV1.billing.attach({
customer_id: customerId,
product_id: pro.id,
discounts: [{ reward_id: pctCoupon.id }, { reward_id: amtCoupon.id }],
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerProducts({
customer,
active: [pro.id],
notPresent: [free.id],
});
// 20% off $20 = $16, then $2 off = $14
await expectCustomerInvoiceCorrect({
customer,
count: 1,
latestTotal: 14,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 5: Duplicate reward deduped
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer on free product
* - Create 20% off coupon
* - Attach pro with same coupon passed twice in discounts array
*
* Expected:
* - Only one discount applied (deduped by coupon ID)
* - Invoice = $20 * 0.8 = $16 (not $20 * 0.8 * 0.8)
*/
test.concurrent(`${chalk.yellowBright("attach-discount 5: duplicate reward deduped")}`, async () => {
const customerId = "att-disc-dedup";
const free = products.base({
id: "free",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 500 })],
});
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [free, pro] }),
],
actions: [s.billing.attach({ productId: free.id })],
});
const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env });
const coupon = await createPercentCoupon({ stripeCli, percentOff: 20 });
await autumnV1.billing.attach({
customer_id: customerId,
product_id: pro.id,
discounts: [{ reward_id: coupon.id }, { reward_id: coupon.id }],
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerProducts({
customer,
active: [pro.id],
notPresent: [free.id],
});
// Only one 20% discount, not double
await expectCustomerInvoiceCorrect({
customer,
count: 1,
latestTotal: 16,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 6: Upgrade pro to premium with reward
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer on pro ($20/mo)
* - Create 20% off coupon
* - Upgrade to premium ($50/mo) with discount
*
* Expected:
* - At start of cycle: refund -$20 (full pro), charge $50 (full premium)
* - Discount applies to charge: $50 * 0.8 = $40
* - Total: -$20 + $40 = $20
*/
test.concurrent(`${chalk.yellowBright("attach-discount 6: upgrade pro to premium with reward")}`, async () => {
const customerId = "att-disc-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 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, premium] }),
],
actions: [s.billing.attach({ productId: pro.id })],
});
const { stripeCli } = await getStripeSubscription({ customerId });
const coupon = await createPercentCoupon({ stripeCli, percentOff: 20 });
// Preview should include discount
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: premium.id,
discounts: [{ reward_id: coupon.id }],
});
// Refund -$20 + discounted charge ($50 * 0.8 = $40) = $20
expect(preview.total).toBe(20);
// Execute attach
await autumnV1.billing.attach({
customer_id: customerId,
product_id: premium.id,
discounts: [{ reward_id: coupon.id }],
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerProducts({
customer,
active: [premium.id],
notPresent: [pro.id],
});
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: 1000,
balance: 1000,
usage: 0,
});
// Invoices: pro ($20) + upgrade ($20)
await expectCustomerInvoiceCorrect({
customer,
count: 2,
latestTotal: 20,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 7: Preview includes discount and matches execution
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer on free product
* - Create 25% off coupon
* - Preview attach pro ($20/mo) with discount
* - Execute attach with same discount
*
* Expected:
* - Preview total = $20 * 0.75 = $15
* - Invoice total matches preview
*/
test.concurrent(`${chalk.yellowBright("attach-discount 7: preview matches execution with discount")}`, async () => {
const customerId = "att-disc-preview";
const free = products.base({
id: "free",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 500 })],
});
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [free, pro] }),
],
actions: [s.billing.attach({ productId: free.id })],
});
const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env });
const coupon = await createPercentCoupon({ stripeCli, percentOff: 25 });
// Preview
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: pro.id,
discounts: [{ reward_id: coupon.id }],
});
expect(preview.total).toBe(15);
// Execute
await autumnV1.billing.attach({
customer_id: customerId,
product_id: pro.id,
discounts: [{ reward_id: coupon.id }],
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Invoice total matches preview
await expectCustomerInvoiceCorrect({
customer,
count: 1,
latestTotal: preview.total,
});
});

View File

@@ -0,0 +1,269 @@
/**
* Integration tests for error handling when attaching with invalid discounts.
*
* Tests error cases:
* - Invalid coupon ID (doesn't exist in Stripe)
* - Invalid promotion code (doesn't exist or inactive)
* - Expired coupon
* - Mixed valid + invalid rewards (entire request fails)
* - Preview with invalid reward also fails
*/
import { test } from "bun:test";
import { ErrCode } from "@autumn/shared";
import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils.js";
import { items } from "@tests/utils/fixtures/items.js";
import { products } from "@tests/utils/fixtures/products.js";
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
import chalk from "chalk";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import { createPercentCoupon } from "../../utils/discounts/discountTestUtils.js";
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 1: Invalid coupon ID
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer on free product
* - Attach pro with a fake coupon ID that doesn't exist in Stripe
*
* Expected:
* - ErrCode.InvalidRequest error
*/
test.concurrent(`${chalk.yellowBright("attach-discount-error 1: invalid coupon ID")}`, async () => {
const customerId = "att-disc-err-bad-coupon";
const free = products.base({
id: "free",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 500 })],
});
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [free, pro] }),
],
actions: [s.billing.attach({ productId: free.id })],
});
await expectAutumnError({
errCode: ErrCode.InvalidRequest,
func: async () => {
await autumnV1.billing.attach({
customer_id: customerId,
product_id: pro.id,
discounts: [{ reward_id: "fake_coupon_does_not_exist" }],
});
},
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 2: Invalid promotion code
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer on free product
* - Attach pro with a fake promo code string
*
* Expected:
* - ErrCode.InvalidRequest error
*/
test.concurrent(`${chalk.yellowBright("attach-discount-error 2: invalid promotion code")}`, async () => {
const customerId = "att-disc-err-bad-promo";
const free = products.base({
id: "free",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 500 })],
});
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [free, pro] }),
],
actions: [s.billing.attach({ productId: free.id })],
});
await expectAutumnError({
errCode: ErrCode.InvalidRequest,
func: async () => {
await autumnV1.billing.attach({
customer_id: customerId,
product_id: pro.id,
discounts: [{ promotion_code: "NONEXISTENT_CODE_12345" }],
});
},
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 3: Expired coupon
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer on free product
* - Create a coupon in Stripe, then immediately delete it (making it invalid)
* - Attach pro with the deleted coupon
*
* Expected:
* - ErrCode.InvalidRequest error
*
* Note: We delete the coupon rather than setting redeem_by in the past,
* because Stripe doesn't allow creating coupons with redeem_by in the past.
*/
test.concurrent(`${chalk.yellowBright("attach-discount-error 3: deleted coupon")}`, async () => {
const customerId = "att-disc-err-deleted";
const free = products.base({
id: "free",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 500 })],
});
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [free, pro] }),
],
actions: [s.billing.attach({ productId: free.id })],
});
const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env });
const coupon = await createPercentCoupon({ stripeCli, percentOff: 10 });
// Delete the coupon to make it invalid
await stripeCli.coupons.del(coupon.id);
await expectAutumnError({
errCode: ErrCode.InvalidRequest,
func: async () => {
await autumnV1.billing.attach({
customer_id: customerId,
product_id: pro.id,
discounts: [{ reward_id: coupon.id }],
});
},
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 4: Mixed valid and invalid rewards (entire request fails)
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer on free product
* - Create one valid coupon
* - Attach pro with one valid and one fake coupon
*
* Expected:
* - Entire request fails with ErrCode.InvalidRequest
* - The valid coupon is not applied
*/
test.concurrent(`${chalk.yellowBright("attach-discount-error 4: mixed valid and invalid rewards")}`, async () => {
const customerId = "att-disc-err-mixed";
const free = products.base({
id: "free",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 500 })],
});
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [free, pro] }),
],
actions: [s.billing.attach({ productId: free.id })],
});
const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env });
const validCoupon = await createPercentCoupon({ stripeCli, percentOff: 20 });
await expectAutumnError({
errCode: ErrCode.InvalidRequest,
func: async () => {
await autumnV1.billing.attach({
customer_id: customerId,
product_id: pro.id,
discounts: [
{ reward_id: validCoupon.id },
{ reward_id: "fake_coupon_xxx" },
],
});
},
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 5: Preview with invalid reward also fails
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer on free product
* - Preview attach pro with a fake coupon ID
*
* Expected:
* - ErrCode.InvalidRequest error (preview validates discounts too)
*/
test.concurrent(`${chalk.yellowBright("attach-discount-error 5: preview with invalid reward fails")}`, async () => {
const customerId = "att-disc-err-preview";
const free = products.base({
id: "free",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 500 })],
});
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [free, pro] }),
],
actions: [s.billing.attach({ productId: free.id })],
});
await expectAutumnError({
errCode: ErrCode.InvalidRequest,
func: async () => {
await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: pro.id,
discounts: [{ reward_id: "nonexistent_coupon_id" }],
});
},
});
});

View File

@@ -0,0 +1,369 @@
/**
* Integration tests for discount stacking when attaching with existing subscription discounts.
*
* Tests how param discounts interact with pre-existing Stripe subscription discounts:
* - Param discounts merge with existing subscription discounts
* - Duplicate coupons are deduplicated
* - Percent + amount stacking order is preserved
* - Multiple param discounts + existing discounts all stack correctly
* - New subscription with discount (no existing discounts)
*/
import { expect, test } from "bun:test";
import type { ApiCustomerV3 } from "@autumn/shared";
import { expectCustomerProducts } from "@tests/integration/billing/utils/expectCustomerProductCorrect.js";
import { items } from "@tests/utils/fixtures/items.js";
import { products } from "@tests/utils/fixtures/products.js";
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
import chalk from "chalk";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import {
applySubscriptionDiscount,
createAmountCoupon,
createPercentCoupon,
getStripeSubscription,
} from "../../utils/discounts/discountTestUtils.js";
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 1: Param discount stacks with existing sub discount
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer on pro ($20/mo) with 10% coupon already on subscription
* - Upgrade to premium ($50/mo) with 20% param discount
*
* Expected:
* - Both discounts applied to charge
* - Charge: $50, 10% off = $45, 20% off = $36
* - Refund: -$20
* - Total: -$20 + $36 = $16
*/
test.concurrent(`${chalk.yellowBright("attach-discount-stacking 1: param discount stacks with existing sub discount")}`, async () => {
const customerId = "att-disc-stack-exist";
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 500 })],
});
const premium = products.premium({
id: "premium",
items: [items.monthlyMessages({ includedUsage: 1000 })],
});
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, premium] }),
],
actions: [s.billing.attach({ productId: pro.id })],
});
const { stripeCli, subscription } = await getStripeSubscription({
customerId,
});
// Apply 10% discount to existing subscription
const existingCoupon = await createPercentCoupon({
stripeCli,
percentOff: 10,
});
await applySubscriptionDiscount({
stripeCli,
subscriptionId: subscription.id,
couponIds: [existingCoupon.id],
});
// Create param discount: 20% off
const paramCoupon = await createPercentCoupon({
stripeCli,
percentOff: 20,
});
// Preview upgrade with param discount
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: premium.id,
discounts: [{ reward_id: paramCoupon.id }],
});
// Refund -$20 + charge $50 * 0.9 * 0.8 = $36 => total $16
expect(preview.total).toBe(16);
// Execute
await autumnV1.billing.attach({
customer_id: customerId,
product_id: premium.id,
discounts: [{ reward_id: paramCoupon.id }],
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerProducts({
customer,
active: [premium.id],
notPresent: [pro.id],
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 2: Duplicate coupon deduped with existing sub discount
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer on pro ($20/mo) with 20% coupon on subscription
* - Upgrade to premium ($50/mo) with same coupon as param discount
*
* Expected:
* - Deduped: only one instance of the coupon
* - Charge: $50 * 0.8 = $40
* - Refund: -$20
* - Total: -$20 + $40 = $20
*/
test.concurrent(`${chalk.yellowBright("attach-discount-stacking 2: duplicate coupon deduped with existing")}`, async () => {
const customerId = "att-disc-stack-dedup";
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 500 })],
});
const premium = products.premium({
id: "premium",
items: [items.monthlyMessages({ includedUsage: 1000 })],
});
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, premium] }),
],
actions: [s.billing.attach({ productId: pro.id })],
});
const { stripeCli, subscription } = await getStripeSubscription({
customerId,
});
// Apply 20% coupon to existing subscription
const coupon = await createPercentCoupon({ stripeCli, percentOff: 20 });
await applySubscriptionDiscount({
stripeCli,
subscriptionId: subscription.id,
couponIds: [coupon.id],
});
// Pass the same coupon as param discount
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: premium.id,
discounts: [{ reward_id: coupon.id }],
});
// Only one 20% discount (deduped): $50 * 0.8 = $40, refund -$20, total $20
expect(preview.total).toBe(20);
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 3: Param amount + existing percent stack correctly
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer on pro ($20/mo) with 30% coupon on subscription
* - Upgrade to premium ($50/mo) with $5 off param discount
*
* Expected:
* - Percent applied first: $50 * 0.7 = $35
* - Then amount: $35 - $5 = $30
* - Refund: -$20
* - Total: -$20 + $30 = $10
*/
test.concurrent(`${chalk.yellowBright("attach-discount-stacking 3: param amount + existing percent")}`, async () => {
const customerId = "att-disc-stack-mixed";
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 500 })],
});
const premium = products.premium({
id: "premium",
items: [items.monthlyMessages({ includedUsage: 1000 })],
});
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, premium] }),
],
actions: [s.billing.attach({ productId: pro.id })],
});
const { stripeCli, subscription } = await getStripeSubscription({
customerId,
});
// Existing: 30% off
const existingCoupon = await createPercentCoupon({
stripeCli,
percentOff: 30,
});
await applySubscriptionDiscount({
stripeCli,
subscriptionId: subscription.id,
couponIds: [existingCoupon.id],
});
// Param: $5 off
const paramCoupon = await createAmountCoupon({
stripeCli,
amountOffCents: 500,
});
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: premium.id,
discounts: [{ reward_id: paramCoupon.id }],
});
// Charge $50 * 0.7 = $35, then $5 off = $30, refund -$20, total $10
expect(preview.total).toBe(10);
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 4: Multiple param discounts + existing discount
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer on pro ($20/mo) with 10% coupon on subscription
* - Upgrade to premium ($50/mo) with two param discounts: 20% + $3 off
*
* Expected:
* - Three discounts total: 10%, 20%, $3 off
* - Charge: $50 * 0.9 * 0.8 = $36, then $3 off = $33
* - Refund: -$20
* - Total: -$20 + $33 = $13
*/
test.concurrent(`${chalk.yellowBright("attach-discount-stacking 4: multiple param discounts + existing")}`, async () => {
const customerId = "att-disc-stack-multi";
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 500 })],
});
const premium = products.premium({
id: "premium",
items: [items.monthlyMessages({ includedUsage: 1000 })],
});
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, premium] }),
],
actions: [s.billing.attach({ productId: pro.id })],
});
const { stripeCli, subscription } = await getStripeSubscription({
customerId,
});
// Existing: 10% off
const existingCoupon = await createPercentCoupon({
stripeCli,
percentOff: 10,
});
await applySubscriptionDiscount({
stripeCli,
subscriptionId: subscription.id,
couponIds: [existingCoupon.id],
});
// Param: 20% off + $3 off
const pctCoupon = await createPercentCoupon({ stripeCli, percentOff: 20 });
const amtCoupon = await createAmountCoupon({
stripeCli,
amountOffCents: 300,
});
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: premium.id,
discounts: [{ reward_id: pctCoupon.id }, { reward_id: amtCoupon.id }],
});
// Charge $50 * 0.9 * 0.8 = $36, $36 - $3 = $33, refund -$20, total $13
expect(preview.total).toBe(13);
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 5: Discount on fresh subscription (no existing discounts)
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer on free product (no Stripe subscription)
* - Attach pro ($20/mo) with 50% param discount
*
* Expected:
* - New subscription created with discount
* - Invoice = $20 * 0.5 = $10
*/
test.concurrent(`${chalk.yellowBright("attach-discount-stacking 5: discount on fresh subscription")}`, async () => {
const customerId = "att-disc-stack-fresh";
const free = products.base({
id: "free",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 500 })],
});
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [free, pro] }),
],
actions: [s.billing.attach({ productId: free.id })],
});
const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env });
const coupon = await createPercentCoupon({ stripeCli, percentOff: 50 });
// Preview
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: pro.id,
discounts: [{ reward_id: coupon.id }],
});
expect(preview.total).toBe(10);
// Execute
await autumnV1.billing.attach({
customer_id: customerId,
product_id: pro.id,
discounts: [{ reward_id: coupon.id }],
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerProducts({
customer,
active: [pro.id],
notPresent: [free.id],
});
});

View File

@@ -787,8 +787,8 @@ test.concurrent(`${chalk.yellowBright("trial-entity-upgrade 5: both entities upg
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerInvoiceCorrect({
customer,
latestTotal: 0,
count: 4,
latestTotal: 0,
});
// Verify Stripe subscription state

View File

@@ -188,3 +188,25 @@ export const removeCustomerDiscount = async ({
});
}
};
/**
* Create a Stripe promotion code wrapping a coupon.
* Code is made unique per-call to avoid collisions in concurrent tests.
*/
export const createPromotionCode = async ({
stripeCli,
coupon,
code,
}: {
stripeCli: Stripe;
coupon: Stripe.Coupon;
code: string;
}) => {
return stripeCli.promotionCodes.create({
promotion: {
type: "coupon",
coupon: coupon.id,
},
code: `${code}${Date.now()}`,
});
};

View File

@@ -0,0 +1,61 @@
import { test } from "bun:test";
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";
/**
* Multi-Version Update Subscription Scenario
*
* Tests upgrading a customer from a simple v1 product to a more complex v2
* with prepaid prices, consumable usage, and additional features.
*
* v1: Simple - $20/month base price + 100 free monthly messages
* v2: Complex - $40/month base price + prepaid credits ($10/100 units) + consumable words + dashboard access
*
* Flow: attach v1 → create v2 → update subscription to v2
*/
test(`${chalk.yellowBright("multi-version: simple v1 → complex v2 with prepaid prices")}`, async () => {
const customerId = "multi-version-update";
// v1: Simple product - flat price + free monthly messages
const messagesItemV1 = items.monthlyMessages({ includedUsage: 100 });
const priceItemV1 = items.monthlyPrice({ price: 20 });
const pro = products.base({
id: "pro",
items: [messagesItemV1, priceItemV1],
});
// Attach v1 to customer
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [s.attach({ productId: "pro" })],
});
// Create v2: More complex with prepaid messages, consumable words, dashboard, and higher base price
const priceItemV2 = items.monthlyPrice({ price: 40 });
const prepaidCreditsV2 = items.prepaid({
featureId: "credits",
includedUsage: 0,
billingUnits: 100,
price: 10,
});
const consumableWordsV2 = items.consumableWords({ includedUsage: 0 });
const dashboardV2 = items.dashboard();
await autumnV1.products.update(pro.id, {
items: [priceItemV2, prepaidCreditsV2, consumableWordsV2, dashboardV2],
});
// // Update subscription to v2
// await autumnV1.subscriptions.update({
// customer_id: customerId,
// product_id: pro.id,
// version: 2,
// });
});

View File

@@ -1,308 +0,0 @@
/**
* Unit tests for setupStripeDiscountsForBilling function.
*
* Tests discount retrieval priority logic:
* - Subscription discounts take priority over customer discounts
* - Falls back to customer discount when no subscription discounts
* - Returns empty array when no discounts exist
* - Handles edge cases (string refs, missing coupons)
*/
import { describe, expect, test } from "bun:test";
import type { StripeDiscountWithCoupon } from "@autumn/shared";
import { discounts } from "@tests/utils/fixtures/db/discounts";
import { stripeCustomers } from "@tests/utils/fixtures/stripe/customers";
import { stripeSubscriptions } from "@tests/utils/fixtures/stripe/subscriptions";
import chalk from "chalk";
import type Stripe from "stripe";
import type {
StripeCustomerWithDiscount,
StripeSubscriptionWithDiscounts,
} from "@/external/stripe/subscriptions";
import { setupStripeDiscountsForBilling } from "@/internal/billing/v2/providers/stripe/setup/setupStripeDiscountsForBilling";
// ============ TESTS ============
describe(chalk.yellowBright("setupStripeDiscountsForBilling"), () => {
const createStripeCustomer = (params?: {
id?: string;
discount?: StripeCustomerWithDiscount["discount"];
}) => stripeCustomers.create(params) as StripeCustomerWithDiscount;
const normalizeStripeCouponAppliesTo = (
coupon: StripeDiscountWithCoupon["source"]["coupon"],
) => {
const couponObject = coupon as Stripe.Coupon;
return {
...couponObject,
applies_to: couponObject.applies_to ?? null,
};
};
const toSubscriptionDiscounts = (
stripeDiscounts: StripeDiscountWithCoupon[],
) =>
stripeDiscounts.map((discount) => ({
...discount,
source: {
...discount.source,
coupon: normalizeStripeCouponAppliesTo(discount.source.coupon),
},
})) as StripeSubscriptionWithDiscounts["discounts"];
const toCustomerDiscount = (discount: StripeDiscountWithCoupon) =>
({
...discount,
coupon: normalizeStripeCouponAppliesTo(discount.source.coupon),
}) as StripeCustomerWithDiscount["discount"];
const createStripeSubscription = (params: {
id: string;
items?: { id: string; priceId: string; quantity: number }[];
discounts?: StripeSubscriptionWithDiscounts["discounts"];
}) => stripeSubscriptions.create(params) as StripeSubscriptionWithDiscounts;
describe(chalk.cyan("No discounts"), () => {
test("returns empty array when no subscription and no customer discount", () => {
const customer = createStripeCustomer();
const result = setupStripeDiscountsForBilling({
stripeSubscription: undefined,
stripeCustomer: customer,
});
expect(result).toEqual([]);
});
test("returns empty array when subscription has no discounts and customer has no discount", () => {
const sub = createStripeSubscription({ id: "sub_test", discounts: [] });
const customer = createStripeCustomer();
const result = setupStripeDiscountsForBilling({
stripeSubscription: sub,
stripeCustomer: customer,
});
expect(result).toEqual([]);
});
});
describe(chalk.cyan("Subscription discounts priority"), () => {
test("returns subscription discounts when present", () => {
const subDiscount = discounts.twentyPercentOff({
couponId: "sub_coupon",
});
const sub = createStripeSubscription({
id: "sub_test",
discounts: toSubscriptionDiscounts([subDiscount]),
});
const customer = createStripeCustomer();
const result = setupStripeDiscountsForBilling({
stripeSubscription: sub,
stripeCustomer: customer,
});
expect(result).toHaveLength(1);
expect(result[0].source.coupon.id).toBe("sub_coupon");
expect(result[0].source.coupon.percent_off).toBe(20);
});
test("returns subscription discounts even when customer has discount", () => {
const subDiscount = discounts.tenPercentOff({ couponId: "sub_coupon" });
const customerDiscount = discounts.fiftyPercentOff({
couponId: "cus_coupon",
});
const sub = createStripeSubscription({
id: "sub_test",
discounts: toSubscriptionDiscounts([subDiscount]),
});
const customer = createStripeCustomer({
discount: toCustomerDiscount(customerDiscount),
});
const result = setupStripeDiscountsForBilling({
stripeSubscription: sub,
stripeCustomer: customer,
});
// Should return subscription discount, not customer discount
expect(result).toHaveLength(1);
expect(result[0].source.coupon.id).toBe("sub_coupon");
expect(result[0].source.coupon.percent_off).toBe(10);
});
test("returns multiple subscription discounts", () => {
const discount1 = discounts.tenPercentOff({ couponId: "coupon_1" });
const discount2 = discounts.twentyDollarsOff({ couponId: "coupon_2" });
const sub = createStripeSubscription({
id: "sub_test",
discounts: toSubscriptionDiscounts([discount1, discount2]),
});
const customer = createStripeCustomer();
const result = setupStripeDiscountsForBilling({
stripeSubscription: sub,
stripeCustomer: customer,
});
expect(result).toHaveLength(2);
expect(result[0].source.coupon.id).toBe("coupon_1");
expect(result[1].source.coupon.id).toBe("coupon_2");
});
});
describe(chalk.cyan("Customer discount fallback"), () => {
test("returns customer discount when no subscription", () => {
const customerDiscount = discounts.percentOff({
percentOff: 30,
couponId: "cus_coupon",
});
const customer = createStripeCustomer({
discount: toCustomerDiscount(customerDiscount),
});
const result = setupStripeDiscountsForBilling({
stripeSubscription: undefined,
stripeCustomer: customer,
});
expect(result).toHaveLength(1);
expect(result[0].source.coupon.id).toBe("cus_coupon");
expect(result[0].source.coupon.percent_off).toBe(30);
});
test("returns customer discount when subscription has no discounts", () => {
const customerDiscount = discounts.tenDollarsOff({
couponId: "cus_coupon",
});
const sub = createStripeSubscription({ id: "sub_test", discounts: [] });
const customer = createStripeCustomer({
discount: toCustomerDiscount(customerDiscount),
});
const result = setupStripeDiscountsForBilling({
stripeSubscription: sub,
stripeCustomer: customer,
});
expect(result).toHaveLength(1);
expect(result[0].source.coupon.id).toBe("cus_coupon");
expect(result[0].source.coupon.amount_off).toBe(1000);
});
test("returns customer discount when subscription discounts are all invalid", () => {
const customerDiscount = discounts.twentyPercentOff({
couponId: "cus_coupon",
});
// Subscription with only string refs (invalid)
const sub = createStripeSubscription({
id: "sub_test",
discounts: [
"di_string_ref",
] as StripeSubscriptionWithDiscounts["discounts"],
});
const customer = createStripeCustomer({
discount: toCustomerDiscount(customerDiscount),
});
const result = setupStripeDiscountsForBilling({
stripeSubscription: sub,
stripeCustomer: customer,
});
expect(result).toHaveLength(1);
expect(result[0].source.coupon.id).toBe("cus_coupon");
});
});
describe(chalk.cyan("Customer discount edge cases"), () => {
test("returns empty array when customer discount has string coupon ref", () => {
const invalidDiscount = {
id: "di_invalid",
object: "discount",
start: Date.now() / 1000,
source: {
coupon: "coupon_string_ref", // Not expanded
type: "coupon",
},
};
const customer = createStripeCustomer({
discount: invalidDiscount as never,
});
const result = setupStripeDiscountsForBilling({
stripeSubscription: undefined,
stripeCustomer: customer,
});
expect(result).toEqual([]);
});
test("returns empty array when customer discount has no source.coupon", () => {
const invalidDiscount = {
id: "di_invalid",
object: "discount",
start: Date.now() / 1000,
source: {
type: "coupon",
},
};
const customer = createStripeCustomer({
discount: invalidDiscount as never,
});
const result = setupStripeDiscountsForBilling({
stripeSubscription: undefined,
stripeCustomer: customer,
});
expect(result).toEqual([]);
});
});
describe(chalk.cyan("Discount properties preserved"), () => {
test("preserves applies_to restrictions from subscription discount", () => {
const discount = discounts.twentyPercentOff({
appliesToProducts: ["prod_a", "prod_b"],
});
const sub = createStripeSubscription({
id: "sub_test",
discounts: toSubscriptionDiscounts([discount]),
});
const customer = createStripeCustomer();
const result = setupStripeDiscountsForBilling({
stripeSubscription: sub,
stripeCustomer: customer,
});
expect(result[0].source.coupon.applies_to?.products).toEqual([
"prod_a",
"prod_b",
]);
});
test("preserves applies_to restrictions from customer discount", () => {
const discount = discounts.tenDollarsOff({
appliesToProducts: ["prod_x"],
});
const customer = createStripeCustomer({
discount: toCustomerDiscount(discount),
});
const result = setupStripeDiscountsForBilling({
stripeSubscription: undefined,
stripeCustomer: customer,
});
expect(result[0].source.coupon.applies_to?.products).toEqual(["prod_x"]);
});
});
});

View File

@@ -1,15 +1,10 @@
import type { StripeDiscountWithCoupon } from "@autumn/shared";
import type Stripe from "stripe";
// ═══════════════════════════════════════════════════════════════════
// PERCENT-OFF DISCOUNTS
// ═══════════════════════════════════════════════════════════════════
/**
* Create a percent-off discount
* @param percentOff - Percentage discount (e.g., 20 for 20%)
* @param appliesToProducts - Optional list of Stripe product IDs this discount applies to
* @param couponId - Optional coupon ID (default: "coupon_percent")
*/
const percentOff = ({
percentOff,
appliesToProducts,
@@ -18,42 +13,18 @@ const percentOff = ({
percentOff: number;
appliesToProducts?: string[];
couponId?: string;
}): StripeDiscountWithCoupon => {
const now = Date.now() / 1000;
return {
id: `di_${couponId}`,
object: "discount",
checkout_session: null,
customer: null,
end: null,
invoice: null,
invoice_item: null,
promotion_code: null,
start: now,
subscription: null,
subscription_item: null,
source: {
coupon: {
id: couponId,
object: "coupon",
percent_off: percentOff,
amount_off: null,
currency: null,
applies_to: appliesToProducts
? { products: appliesToProducts }
: undefined,
created: now,
livemode: false,
valid: true,
} as StripeDiscountWithCoupon["source"]["coupon"],
type: "coupon",
},
};
};
}): StripeDiscountWithCoupon => ({
source: {
coupon: buildCoupon({
couponId,
percent_off: percentOff,
amount_off: null,
currency: null,
appliesToProducts,
}),
},
});
/**
* 10% off discount
*/
const tenPercentOff = ({
appliesToProducts,
couponId = "coupon_10_percent",
@@ -63,9 +34,6 @@ const tenPercentOff = ({
} = {}): StripeDiscountWithCoupon =>
percentOff({ percentOff: 10, appliesToProducts, couponId });
/**
* 20% off discount
*/
const twentyPercentOff = ({
appliesToProducts,
couponId = "coupon_20_percent",
@@ -75,9 +43,6 @@ const twentyPercentOff = ({
} = {}): StripeDiscountWithCoupon =>
percentOff({ percentOff: 20, appliesToProducts, couponId });
/**
* 50% off discount
*/
const fiftyPercentOff = ({
appliesToProducts,
couponId = "coupon_50_percent",
@@ -87,9 +52,6 @@ const fiftyPercentOff = ({
} = {}): StripeDiscountWithCoupon =>
percentOff({ percentOff: 50, appliesToProducts, couponId });
/**
* 100% off discount (free)
*/
const hundredPercentOff = ({
appliesToProducts,
couponId = "coupon_100_percent",
@@ -103,13 +65,6 @@ const hundredPercentOff = ({
// AMOUNT-OFF DISCOUNTS
// ═══════════════════════════════════════════════════════════════════
/**
* Create an amount-off discount
* @param amountOffCents - Amount off in Stripe cents (e.g., 1000 for $10)
* @param currency - Currency code (default: "usd")
* @param appliesToProducts - Optional list of Stripe product IDs this discount applies to
* @param couponId - Optional coupon ID (default: "coupon_amount")
*/
const amountOff = ({
amountOffCents,
currency = "usd",
@@ -120,42 +75,18 @@ const amountOff = ({
currency?: string;
appliesToProducts?: string[];
couponId?: string;
}): StripeDiscountWithCoupon => {
const now = Date.now() / 1000;
return {
id: `di_${couponId}`,
object: "discount",
checkout_session: null,
customer: null,
end: null,
invoice: null,
invoice_item: null,
promotion_code: null,
start: now,
subscription: null,
subscription_item: null,
source: {
coupon: {
id: couponId,
object: "coupon",
percent_off: null,
amount_off: amountOffCents,
currency,
applies_to: appliesToProducts
? { products: appliesToProducts }
: undefined,
created: now,
livemode: false,
valid: true,
} as StripeDiscountWithCoupon["source"]["coupon"],
type: "coupon",
},
};
};
}): StripeDiscountWithCoupon => ({
source: {
coupon: buildCoupon({
couponId,
percent_off: null,
amount_off: amountOffCents,
currency,
appliesToProducts,
}),
},
});
/**
* $5 off discount (500 cents)
*/
const fiveDollarsOff = ({
appliesToProducts,
couponId = "coupon_5_off",
@@ -165,9 +96,6 @@ const fiveDollarsOff = ({
} = {}): StripeDiscountWithCoupon =>
amountOff({ amountOffCents: 500, appliesToProducts, couponId });
/**
* $10 off discount (1000 cents)
*/
const tenDollarsOff = ({
appliesToProducts,
couponId = "coupon_10_off",
@@ -177,9 +105,6 @@ const tenDollarsOff = ({
} = {}): StripeDiscountWithCoupon =>
amountOff({ amountOffCents: 1000, appliesToProducts, couponId });
/**
* $20 off discount (2000 cents)
*/
const twentyDollarsOff = ({
appliesToProducts,
couponId = "coupon_20_off",
@@ -189,9 +114,6 @@ const twentyDollarsOff = ({
} = {}): StripeDiscountWithCoupon =>
amountOff({ amountOffCents: 2000, appliesToProducts, couponId });
/**
* $50 off discount (5000 cents)
*/
const fiftyDollarsOff = ({
appliesToProducts,
couponId = "coupon_50_off",
@@ -201,6 +123,36 @@ const fiftyDollarsOff = ({
} = {}): StripeDiscountWithCoupon =>
amountOff({ amountOffCents: 5000, appliesToProducts, couponId });
// ═══════════════════════════════════════════════════════════════════
// HELPERS
// ═══════════════════════════════════════════════════════════════════
/** Builds a partial Stripe.Coupon with only the fields used by the discount system. */
const buildCoupon = ({
couponId,
percent_off,
amount_off,
currency,
appliesToProducts,
}: {
couponId: string;
percent_off: number | null;
amount_off: number | null;
currency: string | null;
appliesToProducts?: string[];
}): Stripe.Coupon =>
({
id: couponId,
object: "coupon",
percent_off,
amount_off,
currency,
applies_to: appliesToProducts ? { products: appliesToProducts } : undefined,
created: Date.now() / 1000,
livemode: false,
valid: true,
}) as Stripe.Coupon;
// ═══════════════════════════════════════════════════════════════════
// EXPORT
// ═══════════════════════════════════════════════════════════════════

View File

@@ -1,3 +1,4 @@
import type { StripeDiscountWithCoupon } from "@autumn/shared";
import type Stripe from "stripe";
/**
@@ -36,7 +37,7 @@ const create = ({
}: {
id: string;
items?: { id: string; priceId: string; quantity: number }[];
discounts?: (Stripe.Discount | string)[];
discounts?: (Stripe.Discount | string | StripeDiscountWithCoupon)[];
}): Stripe.Subscription => {
const subscriptionItems = items.map((item) =>
createItem({

View File

@@ -0,0 +1,8 @@
import { z } from "zod/v4";
export const AttachDiscountSchema = z.union([
z.object({ reward_id: z.string() }),
z.object({ promotion_code: z.string() }),
]);
export type AttachDiscount = z.infer<typeof AttachDiscountSchema>;

View File

@@ -3,6 +3,7 @@ import { PlanTimingSchema } from "../../../models/billingModels/context/attachBi
import { ProductItemSchema } from "../../../models/productV2Models/productItemModels/productItemModels.js";
import { BillingBehaviorSchema } from "../common/billingBehavior.js";
import { BillingParamsBaseSchema } from "../common/billingParamsBase.js";
import { AttachDiscountSchema } from "./attachDiscount.js";
export const RedirectModeSchema = z.enum(["always", "if_required", "never"]);
export type RedirectMode = z.infer<typeof RedirectModeSchema>;
@@ -24,6 +25,8 @@ export const ExtAttachParamsV0Schema = BillingParamsBaseSchema.extend({
plan_schedule: PlanTimingSchema.optional(),
// Discounts to apply (Stripe coupon IDs or human-readable promo code strings)
discounts: z.array(AttachDiscountSchema).optional(),
// Billing behavior for attach operations (product transitions):
// - 'prorate_immediately' (default): Invoice line items are charged immediately
// - 'next_cycle_only': Do NOT create any charges due to the attach

View File

@@ -3,6 +3,7 @@
export * from "./attach/prevVersions/attachBodyV0.js";
export * from "./attach/prevVersions/attachResponseV1.js";
// Attach V2
export * from "./attachV2/attachDiscount.js";
export * from "./attachV2/attachParamsV0.js";
// Checkout

View File

@@ -202,5 +202,6 @@ export * from "./utils/productV2Utils/productItemUtils/convertProductItem/produc
export * from "./utils/productV2Utils/productItemUtils/getProductItemRes.js";
export * from "./utils/productV2Utils/productItemUtils/itemIntervalUtils.js";
export * from "./utils/productV3Utils/productItemUtils/productV3ItemUtils.js";
export * from "./utils/rewardUtils/rewardFilterUtils.js";
export * from "./utils/rewardUtils/rewardMigrationUtils";
export * from "./utils/scopeDefinitions.js";

View File

@@ -1,5 +1,11 @@
import type Stripe from "stripe";
export type StripeDiscountWithCoupon = Stripe.Discount & {
/**
* A discount source with a guaranteed expanded Stripe Coupon object.
* When the discount originates from a promotion code, promotionCodeId
* is included for proper attribution in checkout sessions.
*/
export type StripeDiscountWithCoupon = {
source: { coupon: Stripe.Coupon };
promotionCodeId?: string;
};

View File

@@ -62,11 +62,12 @@ export const priceToStripePrepaidV2Tiers = ({
? "inf"
: new Decimal(tier.up_to ?? 0)
.div(config.billing_units ?? 1)
.ceil()
.toNumber(),
unit_amount_decimal: new Decimal(tier.unit_amount_decimal ?? 0)
.mul(config.billing_units ?? 1)
.toNumber(),
.toString(),
}));
return dividedTiers;

View File

@@ -0,0 +1,52 @@
import type { Reward, RewardProgram } from "../../index.js";
/**
* Checks if a reward is applicable to a specific product.
* A reward applies if:
* - It has `apply_to_all: true` in its discount_config, OR
* - There's a reward program linking it to this product
*/
export const isRewardApplicableToProduct = ({
reward,
rewardPrograms,
productId,
}: {
reward: Reward;
rewardPrograms: RewardProgram[];
productId: string;
}): boolean => {
// Rewards with apply_to_all are applicable to all products
if (reward.discount_config?.apply_to_all) return true;
// Find reward programs that link this reward to products
const linkedPrograms = rewardPrograms.filter(
(program) => program.internal_reward_id === reward.internal_id,
);
// Check if any linked program includes this product
// Note: product_ids defaults to [""] in DB, so filter out empty strings
return linkedPrograms.some((program) => {
const productIds = (program.product_ids || []).filter((id) => id !== "");
return productIds.includes(productId);
});
};
/**
* Filters rewards to only those applicable to a specific product.
* If no productId is provided, returns all rewards.
*/
export const filterRewardsByProduct = ({
rewards,
rewardPrograms,
productId,
}: {
rewards: Reward[];
rewardPrograms: RewardProgram[];
productId: string | undefined;
}): Reward[] => {
if (!productId) return rewards;
return rewards.filter((reward) =>
isRewardApplicableToProduct({ reward, rewardPrograms, productId }),
);
};

View File

@@ -4,6 +4,7 @@ import {
type ProductItem,
} from "@autumn/shared";
import { z } from "zod/v4";
import type { FormDiscount } from "./utils/discountUtils";
export const AttachFormSchema = z.object({
productId: z.string(),
@@ -14,6 +15,7 @@ export const AttachFormSchema = z.object({
trialDuration: z.enum(FreeTrialDuration),
trialEnabled: z.boolean(),
planSchedule: z.custom<PlanTiming>().nullable(),
discounts: z.custom<FormDiscount[]>(),
});
export type AttachForm = z.infer<typeof AttachFormSchema>;

View File

@@ -0,0 +1,281 @@
import type { PlanTiming } from "@autumn/shared";
import {
CalendarIcon,
CaretDownIcon,
LightningIcon,
PlusIcon,
} from "@phosphor-icons/react";
import type { Transition, Variants } from "motion/react";
import { AnimatePresence, motion } from "motion/react";
import { useMemo, useState } from "react";
import {
STAGGER_CONTAINER,
STAGGER_ITEM,
} from "@/components/forms/update-subscription-v2/constants/animationConstants";
import { IconButton } from "@/components/v2/buttons/IconButton";
import { IconCheckbox } from "@/components/v2/checkboxes/IconCheckbox";
import {
LAYOUT_TRANSITION,
SheetSection,
} from "@/components/v2/sheets/SharedSheetComponents";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/v2/tooltips/Tooltip";
import { cn } from "@/lib/utils";
import { useAttachFormContext } from "../context/AttachFormProvider";
import { addDiscount } from "../utils/discountUtils";
import { AttachDiscountRow } from "./AttachDiscountRow";
const ACCORDION_EASE = [0.32, 0.72, 0, 1] as const;
const ACCORDION_EXPAND: Transition = {
duration: 0.35,
ease: ACCORDION_EASE,
};
const ACCORDION_COLLAPSE: Transition = {
duration: 0.25,
ease: ACCORDION_EASE,
delay: 0.1,
};
const ACCORDION_CONTENT: Variants = {
hidden: {
transition: { staggerChildren: 0.04, staggerDirection: -1 },
},
visible: {
transition: { delayChildren: 0.15, staggerChildren: 0.06 },
},
};
const ACCORDION_ITEM: Variants = {
hidden: {
opacity: 0,
y: -4,
transition: { duration: 0.12, ease: ACCORDION_EASE },
},
visible: {
opacity: 1,
y: 0,
transition: { duration: 0.25, ease: ACCORDION_EASE },
},
};
export function AttachAdvancedSection() {
const [isOpen, setIsOpen] = useState(false);
const { form, formValues, previewQuery } = useAttachFormContext();
const { planSchedule, discounts } = formValues;
const previewData = previewQuery.data;
const defaultPlanSchedule = useMemo((): PlanTiming => {
if (!previewData) return "immediate";
const hasOutgoing = previewData.outgoing.length > 0;
if (!hasOutgoing) return "immediate";
const incomingPrice = previewData.incoming[0]?.plan.price?.amount ?? 0;
const outgoingPrice = previewData.outgoing[0]?.plan.price?.amount ?? 0;
const isUpgrade = incomingPrice > outgoingPrice;
return isUpgrade ? "immediate" : "end_of_cycle";
}, [previewData]);
const effectivePlanSchedule = planSchedule ?? defaultPlanSchedule;
const hasCustomSchedule =
planSchedule !== null && planSchedule !== defaultPlanSchedule;
const hasDiscounts = discounts.some((d) => {
if ("reward_id" in d) return d.reward_id !== "";
if ("promotion_code" in d) return d.promotion_code !== "";
return false;
});
const hasCustomSettings = hasCustomSchedule || hasDiscounts;
const handleScheduleChange = (value: PlanTiming) => {
form.setFieldValue("planSchedule", value);
};
const handleAddDiscount = () => {
form.setFieldValue("discounts", addDiscount(discounts));
};
const isImmediateSelected = effectivePlanSchedule === "immediate";
const isEndOfCycleSelected = effectivePlanSchedule === "end_of_cycle";
const getCustomSettingsTooltip = (): string => {
const parts: string[] = [];
if (hasCustomSchedule) {
parts.push(
`Plan schedule: ${isImmediateSelected ? "Immediate" : "End of cycle"}`,
);
}
if (hasDiscounts) {
const validCount = discounts.filter((d) => {
if ("reward_id" in d) return d.reward_id !== "";
if ("promotion_code" in d) return d.promotion_code !== "";
return false;
}).length;
parts.push(`${validCount} discount${validCount > 1 ? "s" : ""}`);
}
return parts.join(" • ");
};
return (
<SheetSection withSeparator>
<motion.div
layout="position"
layoutDependency={formValues.productId}
transition={{ layout: LAYOUT_TRANSITION }}
initial="hidden"
animate="visible"
variants={STAGGER_CONTAINER}
>
<motion.div variants={STAGGER_ITEM}>
<button
type="button"
onClick={() => setIsOpen((prev) => !prev)}
className="flex items-center justify-between w-full cursor-pointer select-none"
>
<h3 className="text-sub flex items-center gap-2">
Advanced
<AnimatePresence>
{hasCustomSettings && (
<Tooltip>
<TooltipTrigger asChild>
<motion.span
initial={{ opacity: 0, scale: 0 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0 }}
transition={{ duration: 0.15 }}
className="size-1.5 rounded-full bg-blue-400"
/>
</TooltipTrigger>
<TooltipContent>
{getCustomSettingsTooltip()}
</TooltipContent>
</Tooltip>
)}
</AnimatePresence>
</h3>
<motion.span
animate={{ rotate: isOpen ? 180 : 0 }}
transition={{ duration: 0.2 }}
className="text-t3"
>
<CaretDownIcon size={12} />
</motion.span>
</button>
</motion.div>
<AnimatePresence initial={false}>
{isOpen && (
<motion.div
initial={{ height: 0 }}
animate={{
height: "auto",
transition: {
height: ACCORDION_EXPAND,
},
}}
exit={{
height: 0,
transition: {
height: ACCORDION_COLLAPSE,
},
}}
className="overflow-hidden"
>
<motion.div
className="pt-2 space-y-2"
initial="hidden"
animate="visible"
exit="hidden"
variants={ACCORDION_CONTENT}
>
{/* Plan Schedule */}
<motion.div variants={ACCORDION_ITEM}>
<div className="flex items-center justify-between px-3 h-10 rounded-xl input-base">
<span className="text-sm text-t2">Plan Schedule</span>
<div className="flex">
<IconCheckbox
icon={<LightningIcon />}
iconOrientation="left"
variant="secondary"
size="sm"
checked={isImmediateSelected}
onCheckedChange={() =>
handleScheduleChange("immediate")
}
className={cn(
"rounded-r-none",
!isImmediateSelected && "border-r-0",
)}
>
Immediately
</IconCheckbox>
<IconCheckbox
icon={<CalendarIcon />}
iconOrientation="left"
variant="secondary"
size="sm"
checked={isEndOfCycleSelected}
onCheckedChange={() =>
handleScheduleChange("end_of_cycle")
}
className={cn(
"rounded-l-none",
!isEndOfCycleSelected && "border-l-0",
)}
>
End of cycle
</IconCheckbox>
</div>
</div>
</motion.div>
{/* Discounts */}
<motion.div variants={ACCORDION_ITEM}>
<div className="rounded-xl input-base px-3 py-2">
<div className="flex items-center justify-between h-6">
<span className="text-sm text-t2">Discounts</span>
<IconButton
variant="muted"
size="sm"
onClick={handleAddDiscount}
icon={<PlusIcon size={12} />}
className="text-t3"
>
Add
</IconButton>
</div>
{discounts.length > 0 && (
<div className="mt-2 pt-2 border-t border-border space-y-2">
<AnimatePresence initial={false} mode="popLayout">
{discounts.map((discount, index) => (
<motion.div
key={discount._id}
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.15 }}
>
<AttachDiscountRow index={index} />
</motion.div>
))}
</AnimatePresence>
</div>
)}
</div>
</motion.div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
</SheetSection>
);
}

View File

@@ -0,0 +1,119 @@
import type { Reward } from "@autumn/shared";
import { filterRewardsByProduct, RewardType } from "@autumn/shared";
import { XIcon } from "@phosphor-icons/react";
import { CheckIcon } from "lucide-react";
import { IconButton } from "@/components/v2/buttons/IconButton";
import { SearchableSelect } from "@/components/v2/selects/SearchableSelect";
import { useRewardsQuery } from "@/hooks/queries/useRewardsQuery";
import { useAttachFormContext } from "../context/AttachFormProvider";
import { removeDiscount, updateDiscount } from "../utils/discountUtils";
interface AttachDiscountRowProps {
index: number;
}
/** Filters rewards to only show discount types (not free products) */
const filterDiscountRewards = (rewards: Reward[]): Reward[] => {
return rewards.filter(
(r) =>
r.type === RewardType.PercentageDiscount ||
r.type === RewardType.FixedDiscount,
);
};
export function AttachDiscountRow({ index }: AttachDiscountRowProps) {
const { form, formValues, product } = useAttachFormContext();
const { rewards, rewardPrograms } = useRewardsQuery();
const discounts = formValues.discounts;
const discount = discounts[index];
if (!discount) return null;
const discountRewards = filterDiscountRewards(rewards);
const productFilteredRewards = filterRewardsByProduct({
rewards: discountRewards,
rewardPrograms,
productId: product?.id,
});
// Get reward IDs already selected in other rows
const selectedRewardIds = discounts
.filter((d, i) => i !== index && "reward_id" in d)
.map((d) => ("reward_id" in d ? d.reward_id : ""))
.filter(Boolean);
// Filter out already-selected rewards
const availableRewards = productFilteredRewards.filter(
(r) => !selectedRewardIds.includes(r.id),
);
const handleRewardChange = (rewardId: string) => {
form.setFieldValue(
"discounts",
updateDiscount(discounts, index, { reward_id: rewardId }),
);
};
const handleRemove = () => {
form.setFieldValue("discounts", removeDiscount(discounts, index));
};
const currentRewardId = "reward_id" in discount ? discount.reward_id : "";
return (
<div className="flex items-center gap-2 h-8">
{/* Reward select */}
<div className="flex-1 min-w-0">
<SearchableSelect
value={currentRewardId}
onValueChange={handleRewardChange}
options={availableRewards}
getOptionValue={(r) => r.id}
getOptionLabel={(r) => r.name || r.id}
placeholder="Select reward..."
searchable
searchPlaceholder="Search rewards..."
emptyText="No rewards found"
triggerClassName="h-7 px-2 text-xs border-0 shadow-none bg-transparent hover:bg-muted/50"
renderOption={(reward, isSelected) => (
<>
<span className="flex-1 truncate min-w-0">
{reward.name || reward.id}
</span>
{reward.promo_codes?.[0]?.code && (
<span className="text-t3 text-xs shrink-0">
{reward.promo_codes[0].code}
</span>
)}
{isSelected && <CheckIcon className="size-4 shrink-0" />}
</>
)}
renderValue={(reward) => {
if (!reward)
return <span className="text-t3">Select reward...</span>;
return (
<span className="flex items-center gap-2">
<span className="truncate">{reward.name || reward.id}</span>
{reward.promo_codes?.[0]?.code && (
<span className="text-t3 text-xs shrink-0">
{reward.promo_codes[0].code}
</span>
)}
</span>
);
}}
/>
</div>
{/* Remove button */}
<IconButton
variant="muted"
size="sm"
onClick={handleRemove}
icon={<XIcon size={12} />}
className="shrink-0 text-t3 hover:text-red-500"
/>
</div>
);
}

View File

@@ -4,8 +4,12 @@ import { PlanItemsSection } from "@/components/forms/shared";
import {
STAGGER_CONTAINER,
STAGGER_ITEM,
STAGGER_ITEM_LAYOUT,
} from "@/components/forms/update-subscription-v2/constants/animationConstants";
import { SheetSection } from "@/components/v2/sheets/SharedSheetComponents";
import {
LAYOUT_TRANSITION,
SheetSection,
} from "@/components/v2/sheets/SharedSheetComponents";
import { useOrg } from "@/hooks/common/useOrg";
import { useAttachFormContext } from "../context/AttachFormProvider";
import { outgoingToProductItems } from "../utils/attachDiffUtils";
@@ -85,7 +89,11 @@ export function AttachPlanSection() {
animate="visible"
variants={STAGGER_CONTAINER}
>
<motion.div variants={STAGGER_ITEM}>
<motion.div
layout="position"
transition={{ layout: LAYOUT_TRANSITION }}
variants={STAGGER_ITEM_LAYOUT}
>
<h3 className="text-sub select-none w-full">
<AttachSectionTitle />
</h3>

View File

@@ -1,4 +1,4 @@
import { GearIcon, TimerIcon } from "@phosphor-icons/react";
import { TimerIcon } from "@phosphor-icons/react";
import { motion } from "motion/react";
import {
STAGGER_CONTAINER,
@@ -24,24 +24,14 @@ export function AttachPlanSkeleton() {
<span className="flex items-center gap-1.5">
Plan Configuration
</span>
<span className="flex items-center gap-2">
<IconButton
icon={<GearIcon size={14} />}
variant="secondary"
className="h-7 whitespace-nowrap"
disabled
>
Settings
</IconButton>
<IconButton
icon={<TimerIcon size={14} />}
variant="secondary"
className="h-7 whitespace-nowrap"
disabled
>
Free Trial
</IconButton>
</span>
<IconButton
icon={<TimerIcon size={14} />}
variant="secondary"
className="h-7 whitespace-nowrap"
disabled
>
Free Trial
</IconButton>
</span>
</h3>
</motion.div>

View File

@@ -2,9 +2,11 @@ import type { AxiosError } from "axios";
import { format } from "date-fns";
import { motion } from "motion/react";
import { PreviewErrorDisplay } from "@/components/forms/update-subscription-v2/components/PreviewErrorDisplay";
import { LAYOUT_TRANSITION } from "@/components/forms/update-subscription-v2/constants/animationConstants";
import { LineItemsPreview } from "@/components/v2/LineItemsPreview";
import { SheetSection } from "@/components/v2/sheets/SharedSheetComponents";
import {
LAYOUT_TRANSITION,
SheetSection,
} from "@/components/v2/sheets/SharedSheetComponents";
import { getBackendErr } from "@/utils/genUtils";
import { useAttachFormContext } from "../context/AttachFormProvider";
@@ -43,7 +45,7 @@ export function AttachPreviewSection() {
if (error) {
return (
<motion.div layout transition={LAYOUT_TRANSITION}>
<motion.div layout="position" transition={{ layout: LAYOUT_TRANSITION }}>
<SheetSection title="Pricing Preview" withSeparator>
<PreviewErrorDisplay error={error} />
</SheetSection>
@@ -52,7 +54,7 @@ export function AttachPreviewSection() {
}
return (
<motion.div layout transition={LAYOUT_TRANSITION}>
<motion.div layout="position" transition={{ layout: LAYOUT_TRANSITION }}>
<LineItemsPreview
title="Pricing Preview"
isLoading={isLoading}

View File

@@ -21,6 +21,7 @@ export function AttachProductSelection() {
<field.SelectField
label=""
searchable
defaultOpen
options={availableProducts.map((p) => ({
label: p.name,
value: p.id,

View File

@@ -7,7 +7,6 @@ import {
} from "@/components/v2/tooltips/Tooltip";
import { cn } from "@/lib/utils";
import { useAttachFormContext } from "../context/AttachFormProvider";
import { AttachSettingsPopover } from "./AttachSettingsPopover";
export function AttachSectionTitle() {
const { hasCustomizations, form, formValues } = useAttachFormContext();
@@ -35,36 +34,33 @@ export function AttachSectionTitle() {
</Tooltip>
)}
</span>
<span className="flex items-center gap-2">
<AttachSettingsPopover />
<Tooltip>
<TooltipTrigger asChild>
<IconButton
icon={
<TimerIcon
size={14}
weight={trialIsActive ? "fill" : "regular"}
/>
}
variant="secondary"
className={cn(
"h-7 whitespace-nowrap",
trialIsActive &&
"text-purple-400! border-purple-500/50 bg-purple-500/10",
trialEnabled && !trialIsActive && "border-primary",
)}
onClick={() => form.setFieldValue("trialEnabled", !trialEnabled)}
>
Free Trial
</IconButton>
</TooltipTrigger>
<TooltipContent side="top">
{trialIsActive
? "Trial configured - click to edit"
: "Add a free trial"}
</TooltipContent>
</Tooltip>
</span>
<Tooltip>
<TooltipTrigger asChild>
<IconButton
icon={
<TimerIcon
size={14}
weight={trialIsActive ? "fill" : "regular"}
/>
}
variant="secondary"
className={cn(
"h-7 whitespace-nowrap",
trialIsActive &&
"text-purple-400! border-purple-500/50 bg-purple-500/10",
trialEnabled && !trialIsActive && "border-primary",
)}
onClick={() => form.setFieldValue("trialEnabled", !trialEnabled)}
>
Free Trial
</IconButton>
</TooltipTrigger>
<TooltipContent side="top">
{trialIsActive
? "Trial configured - click to edit"
: "Add a free trial"}
</TooltipContent>
</Tooltip>
</span>
);
}

View File

@@ -3,15 +3,19 @@ import { motion } from "motion/react";
import {
STAGGER_CONTAINER,
STAGGER_ITEM,
STAGGER_ITEM_LAYOUT,
} from "@/components/forms/update-subscription-v2/constants/animationConstants";
import { Skeleton } from "@/components/ui/skeleton";
import { SheetSection } from "@/components/v2/sheets/SharedSheetComponents";
import {
LAYOUT_TRANSITION,
SheetSection,
} from "@/components/v2/sheets/SharedSheetComponents";
import { InfoBox } from "@/views/onboarding2/integrate/components/InfoBox";
import { useAttachFormContext } from "../context/AttachFormProvider";
function AttachUpdatesSkeleton() {
return (
<SheetSection withSeparator>
<SheetSection withSeparator={false}>
<motion.div
initial="hidden"
animate="visible"
@@ -70,13 +74,19 @@ export function AttachUpdatesSection() {
};
return (
<SheetSection withSeparator>
<SheetSection withSeparator={false} className="pb-0">
<motion.div
layout="position"
transition={{ layout: LAYOUT_TRANSITION }}
initial="hidden"
animate="visible"
variants={STAGGER_CONTAINER}
>
<motion.div variants={STAGGER_ITEM}>
<motion.div
layout="position"
transition={{ layout: LAYOUT_TRANSITION }}
variants={STAGGER_ITEM_LAYOUT}
>
<InfoBox variant="note">
Attaching{" "}
<PlusCircleIcon

View File

@@ -96,6 +96,7 @@ export function AttachFormProvider({
trialDuration,
trialEnabled,
planSchedule,
discounts,
} = formValues;
const product = useMemo(
@@ -169,6 +170,7 @@ export function AttachFormProvider({
trialDuration,
trialEnabled,
planSchedule,
discounts,
});
const previewQuery = useAttachPreview({ requestBody });

View File

@@ -19,6 +19,7 @@ export function useAttachForm({
trialDuration: FreeTrialDuration.Day,
trialEnabled: false,
planSchedule: null,
discounts: [],
} as AttachForm,
validators: {
onChange: AttachFormSchema,

View File

@@ -12,6 +12,10 @@ import {
import Decimal from "decimal.js";
import { useMemo } from "react";
import { getFreeTrial } from "@/components/forms/update-subscription-v2/utils/getFreeTrial";
import {
type FormDiscount,
filterValidDiscounts,
} from "../utils/discountUtils";
interface UseAttachRequestBodyParams {
customerId: string | undefined;
@@ -24,6 +28,7 @@ interface UseAttachRequestBodyParams {
trialDuration: FreeTrialDuration;
trialEnabled: boolean;
planSchedule: PlanTiming | null;
discounts: FormDiscount[];
}
function convertPrepaidOptionsToFeatureOptions({
@@ -75,6 +80,7 @@ export function useAttachRequestBody({
trialDuration,
trialEnabled,
planSchedule,
discounts,
}: UseAttachRequestBodyParams) {
const requestBody = useMemo((): AttachParamsV0 | null => {
if (!customerId || !product) {
@@ -125,6 +131,11 @@ export function useAttachRequestBody({
body.plan_schedule = planSchedule;
}
const validDiscounts = filterValidDiscounts(discounts);
if (validDiscounts.length > 0) {
body.discounts = validDiscounts;
}
return body;
}, [
customerId,
@@ -137,6 +148,7 @@ export function useAttachRequestBody({
trialDuration,
trialEnabled,
planSchedule,
discounts,
]);
const buildRequestBody = useMemo(

View File

@@ -2,6 +2,7 @@
// Types
export * from "./attachFormSchema";
export * from "./components/AttachAdvancedSection";
export * from "./components/AttachFooter";
export * from "./components/AttachPlanSection";
export * from "./components/AttachPreviewSection";
@@ -20,3 +21,4 @@ export * from "./hooks/useAttachRequestBody";
// Utils
export * from "./utils/attachDiffUtils";
export * from "./utils/discountUtils";

View File

@@ -0,0 +1,71 @@
import type { AttachDiscount } from "@autumn/shared";
export type DiscountMode = "reward" | "promo";
/** Form discount with unique ID for stable React keys */
export type FormDiscount = AttachDiscount & { _id: string };
let discountIdCounter = 0;
const generateDiscountId = (): string => {
discountIdCounter += 1;
return `discount-${discountIdCounter}-${Date.now()}`;
};
export const getDiscountMode = (discount: FormDiscount): DiscountMode => {
return "reward_id" in discount ? "reward" : "promo";
};
export const createDiscount = (mode: DiscountMode): FormDiscount => {
const base = mode === "reward" ? { reward_id: "" } : { promotion_code: "" };
return { ...base, _id: generateDiscountId() };
};
export const addDiscount = (discounts: FormDiscount[]): FormDiscount[] => {
return [...discounts, createDiscount("reward")];
};
export const removeDiscount = (
discounts: FormDiscount[],
index: number,
): FormDiscount[] => {
return discounts.filter((_, i) => i !== index);
};
export const updateDiscount = (
discounts: FormDiscount[],
index: number,
updates: AttachDiscount,
): FormDiscount[] => {
const newDiscounts = [...discounts];
const existing = newDiscounts[index];
newDiscounts[index] = { ...updates, _id: existing._id } as FormDiscount;
return newDiscounts;
};
export const toggleDiscountMode = (
discounts: FormDiscount[],
index: number,
newMode: DiscountMode,
): FormDiscount[] => {
const base =
newMode === "reward" ? { reward_id: "" } : { promotion_code: "" };
return updateDiscount(discounts, index, base);
};
/** Converts form discounts to API format (strips _id) */
export const toApiDiscounts = (discounts: FormDiscount[]): AttachDiscount[] => {
return discounts.map(({ _id, ...rest }) => rest);
};
/** Filters out empty/invalid discounts before sending to API */
export const filterValidDiscounts = (
discounts: FormDiscount[],
): AttachDiscount[] => {
return toApiDiscounts(
discounts.filter((d) => {
if ("reward_id" in d) return d.reward_id !== "";
if ("promotion_code" in d) return d.promotion_code !== "";
return false;
}),
);
};

View File

@@ -18,13 +18,14 @@ import { TrialEditorRow } from "@/components/forms/update-subscription-v2/compon
import { VersionChangeRow } from "@/components/forms/update-subscription-v2/components/VersionChangeRow";
import {
FAST_TRANSITION,
LAYOUT_TRANSITION,
STAGGER_CONTAINER,
STAGGER_ITEM,
STAGGER_ITEM_LAYOUT,
} from "@/components/forms/update-subscription-v2/constants/animationConstants";
import type { UseTrialStateReturn } from "@/components/forms/update-subscription-v2/hooks/useTrialState";
import type { UseUpdateSubscriptionForm } from "@/components/forms/update-subscription-v2/hooks/useUpdateSubscriptionForm";
import { Button } from "@/components/v2/buttons/Button";
import { LAYOUT_TRANSITION } from "@/components/v2/sheets/SharedSheetComponents";
interface PriceChange {
oldPrice: string;
@@ -197,9 +198,9 @@ export function PlanItemsSection({
return (
<motion.div
key={featureId || item.price_id || index}
layout
variants={useStaggerAnimation ? STAGGER_ITEM : undefined}
transition={LAYOUT_TRANSITION}
layout="position"
variants={useStaggerAnimation ? STAGGER_ITEM_LAYOUT : undefined}
transition={{ layout: LAYOUT_TRANSITION }}
>
<SubscriptionItemRow
item={item}
@@ -216,9 +217,9 @@ export function PlanItemsSection({
const renderDeletedItemRow = (item: ProductItem, index: number) => (
<motion.div
key={`deleted-${item.feature_id || index}`}
layout
variants={useStaggerAnimation ? STAGGER_ITEM : undefined}
transition={LAYOUT_TRANSITION}
layout="position"
variants={useStaggerAnimation ? STAGGER_ITEM_LAYOUT : undefined}
transition={{ layout: LAYOUT_TRANSITION }}
>
<SubscriptionItemRow item={item} isDeleted />
</motion.div>
@@ -229,9 +230,9 @@ export function PlanItemsSection({
return (
<motion.div
key="version-change"
layout
variants={useStaggerAnimation ? STAGGER_ITEM : undefined}
transition={LAYOUT_TRANSITION}
layout="position"
variants={useStaggerAnimation ? STAGGER_ITEM_LAYOUT : undefined}
transition={{ layout: LAYOUT_TRANSITION }}
>
<VersionChangeRow
currentVersion={versionChange.currentVersion}
@@ -250,7 +251,7 @@ export function PlanItemsSection({
<motion.div
key="trial-editor"
layout
transition={LAYOUT_TRANSITION}
transition={{ layout: LAYOUT_TRANSITION }}
variants={useStaggerAnimation ? STAGGER_ITEM : undefined}
>
<TrialEditorRow
@@ -272,19 +273,15 @@ export function PlanItemsSection({
{trialConfig.trialEnabled && (
<motion.div
key="trial-editor"
layout
initial={{ opacity: 0, y: 8 }}
initial={{ opacity: 0 }}
animate={{
opacity: 1,
y: 0,
transition: { ...FAST_TRANSITION, delay: 0.15 },
transition: FAST_TRANSITION,
}}
exit={{
opacity: 0,
y: -8,
transition: FAST_TRANSITION,
}}
transition={LAYOUT_TRANSITION}
>
<TrialEditorRow
form={form}
@@ -298,9 +295,9 @@ export function PlanItemsSection({
const renderEditButton = () => (
<motion.div
layout
variants={useStaggerAnimation ? STAGGER_ITEM : undefined}
transition={LAYOUT_TRANSITION}
layout="position"
transition={{ layout: LAYOUT_TRANSITION }}
variants={useStaggerAnimation ? STAGGER_ITEM_LAYOUT : undefined}
>
<Button variant="secondary" onClick={onEditPlan} className="w-full">
<PencilSimpleIcon size={14} className="mr-1" />
@@ -314,12 +311,16 @@ export function PlanItemsSection({
<LayoutGroup>
<motion.div
className="space-y-2"
layout="position"
transition={{ layout: LAYOUT_TRANSITION }}
initial="hidden"
animate="visible"
variants={STAGGER_CONTAINER}
>
<motion.div
variants={STAGGER_ITEM}
layout="position"
transition={{ layout: LAYOUT_TRANSITION }}
variants={STAGGER_ITEM_LAYOUT}
className="flex gap-2 justify-between items-center"
>
{renderPriceDisplay()}
@@ -339,13 +340,17 @@ export function PlanItemsSection({
{renderPriceDisplay()}
</div>
<LayoutGroup>
<div className="space-y-2">
<motion.div
className="space-y-2"
layout="position"
transition={{ layout: LAYOUT_TRANSITION }}
>
{product?.items?.map(renderItemRow)}
{deletedItems.map(renderDeletedItemRow)}
{renderVersionChangeRow()}
{renderTrialEditor()}
{renderEditButton()}
</div>
</motion.div>
</LayoutGroup>
</>
);

View File

@@ -16,12 +16,13 @@ export function EditPlanSection() {
originalItems,
initialPrepaidOptions,
productWithFormItems: product,
isVersionReady,
handleEditPlan,
} = useUpdateSubscriptionFormContext();
const { customerProduct, numVersions, currentVersion } = formContext;
const { prepaidOptions } = formValues;
const hasCustomizations = formValues.items !== null;
const hasCustomizations = formValues.items !== null || isVersionReady;
const { org } = useOrg();
const currency = org?.default_currency ?? "USD";
@@ -89,7 +90,9 @@ export function EditPlanSection() {
const selectedVersion = form.getFieldValue("version");
const versionChange =
currentVersion !== undefined && selectedVersion !== undefined
isVersionReady &&
currentVersion !== undefined &&
selectedVersion !== undefined
? { currentVersion, selectedVersion }
: null;
@@ -97,7 +100,7 @@ export function EditPlanSection() {
<SheetSection
title={
<SectionTitle
hasCustomizations={hasCustomizations}
hasCustomizations={formValues.items !== null}
form={form}
numVersions={numVersions}
currentVersion={currentVersion}

View File

@@ -179,18 +179,13 @@ export function TrialEditorRow({
</span>
) : null}
</div>
<motion.div
layout
transition={FAST_TRANSITION}
className="flex items-center h-10 px-3 rounded-xl input-base gap-2 overflow-hidden"
>
<div className="flex items-center h-10 px-3 rounded-xl input-base gap-2 overflow-hidden">
<AnimatePresence mode="popLayout" initial={false}>
<motion.div
key="display"
layout
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 10 }}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={FAST_TRANSITION}
className="flex items-center gap-2"
>
@@ -206,7 +201,7 @@ export function TrialEditorRow({
/>
</motion.div>
</AnimatePresence>
</motion.div>
</div>
</div>
);
}
@@ -249,18 +244,13 @@ export function TrialEditorRow({
<span className="text-sm text-t2">Free Trial</span>
</div>
</div>
<motion.div
layout
transition={FAST_TRANSITION}
className="flex items-center h-10 px-3 rounded-xl input-base gap-2 overflow-hidden"
>
<div className="flex items-center h-10 px-3 rounded-xl input-base gap-2 overflow-hidden">
<AnimatePresence mode="popLayout" initial={false}>
<motion.div
key="edit"
layout
initial={{ opacity: 0, x: 10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -10 }}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={FAST_TRANSITION}
className="flex items-center gap-2"
>
@@ -315,7 +305,7 @@ export function TrialEditorRow({
/>
</motion.div>
</AnimatePresence>
</motion.div>
</div>
</div>
);
}

View File

@@ -31,6 +31,15 @@ export const STAGGER_ITEM: Variants = {
},
};
/** Opacity-only stagger item for elements using layout="position" — avoids y transform conflicts with layout animations */
export const STAGGER_ITEM_LAYOUT: Variants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: { duration: 0.25, ease: [0.32, 0.72, 0, 1] },
},
};
export const STAGGER_ITEM_DELAYED: Variants = {
hidden: { opacity: 0, y: 8 },
visible: {

View File

@@ -21,6 +21,7 @@ import {
useUpdateSubscriptionPreview,
} from "@/components/forms/update-subscription/use-update-subscription-preview";
import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery";
import { useProductVersionQuery } from "@/hooks/queries/useProductVersionQuery";
import type { PrepaidItemWithFeature } from "@/hooks/stores/useProductStore";
import { useHasBillingChanges } from "@/hooks/stores/useProductStore";
import { useHasSubscriptionChanges } from "../hooks/useHasSubscriptionChanges";
@@ -62,6 +63,7 @@ interface UpdateSubscriptionFormContextValue {
initialPrepaidOptions: Record<string, number>;
changedPrepaidOptions: Record<string, number> | undefined;
productWithFormItems: FrontendProduct | undefined;
isVersionReady: boolean;
hasChanges: boolean;
hasNoBillingChanges: boolean;
@@ -121,6 +123,23 @@ export function UpdateSubscriptionFormProvider({
const formValues = useStore(form.store, (state) => state.values);
const { prepaidOptions } = formValues;
// Fetch the target version's product data when version differs from current
const isVersionChanged = formValues.version !== currentVersion;
const versionProductQuery = useProductVersionQuery({
productId: product?.id,
version: formValues.version,
enabled: isVersionChanged,
});
// Use the target version's product when a different version is selected
const isVersionReady = isVersionChanged && !!versionProductQuery.data;
const effectiveProduct = useMemo((): ProductV2 | undefined => {
if (isVersionReady) {
return versionProductQuery.data?.product;
}
return product;
}, [product, isVersionReady, versionProductQuery.data]);
const defaultValues = form.options.defaultValues;
const initialPrepaidOptions = defaultValues?.prepaidOptions ?? {};
@@ -145,10 +164,10 @@ export function UpdateSubscriptionFormProvider({
}, [prepaidOptions, initialPrepaidOptions]);
const productWithFormItems = useMemo((): FrontendProduct | undefined => {
if (!product) return undefined;
if (!effectiveProduct) return undefined;
const baseFrontendProduct = productV2ToFrontendProduct({
product: product as ProductV2,
product: effectiveProduct as ProductV2,
});
if (formValues.items) {
@@ -159,7 +178,7 @@ export function UpdateSubscriptionFormProvider({
}
return baseFrontendProduct;
}, [product, formValues.items]);
}, [effectiveProduct, formValues.items]);
const baseProduct = useMemo((): FrontendProduct | undefined => {
if (!product) return undefined;
@@ -167,9 +186,11 @@ export function UpdateSubscriptionFormProvider({
}, [product]);
const newProduct = useMemo((): FrontendProduct | undefined => {
if (!product) return undefined;
if (!effectiveProduct) return undefined;
const base = productV2ToFrontendProduct({ product: product as ProductV2 });
const base = productV2ToFrontendProduct({
product: effectiveProduct as ProductV2,
});
const freeTrial = getFreeTrial({
removeTrial: formValues.removeTrial,
@@ -186,7 +207,7 @@ export function UpdateSubscriptionFormProvider({
free_trial: freeTrialValue,
};
}, [
product,
effectiveProduct,
formValues.items,
formValues.removeTrial,
formValues.trialLength,
@@ -200,8 +221,12 @@ export function UpdateSubscriptionFormProvider({
});
const hasPrepaidQuantityChanges = changedPrepaidOptions !== undefined;
const isVersionLoading = isVersionChanged && !isVersionReady;
const hasNoBillingChanges =
hasChanges && !hasBillingChanges && !hasPrepaidQuantityChanges;
hasChanges &&
!hasBillingChanges &&
!hasPrepaidQuantityChanges &&
!isVersionLoading;
const freeTrial = getFreeTrial({
removeTrial: formValues.removeTrial,
@@ -286,6 +311,7 @@ export function UpdateSubscriptionFormProvider({
initialPrepaidOptions,
changedPrepaidOptions,
productWithFormItems,
isVersionReady,
hasChanges,
hasNoBillingChanges,
previewQuery,
@@ -307,6 +333,7 @@ export function UpdateSubscriptionFormProvider({
initialPrepaidOptions,
changedPrepaidOptions,
productWithFormItems,
isVersionReady,
hasChanges,
hasNoBillingChanges,
previewQuery,

View File

@@ -22,6 +22,7 @@ export function SelectField<T extends string | number = string>({
searchable = false,
searchPlaceholder = "Search...",
emptyText = "No results found",
defaultOpen = false,
}: {
label: string;
options: SelectFieldOption<T>[];
@@ -34,6 +35,7 @@ export function SelectField<T extends string | number = string>({
searchable?: boolean;
searchPlaceholder?: string;
emptyText?: string;
defaultOpen?: boolean;
}) {
const field = useFieldContext<T>();
const stringValue = String(field.state.value);
@@ -60,6 +62,7 @@ export function SelectField<T extends string | number = string>({
searchPlaceholder={searchPlaceholder}
emptyText={emptyText}
disabled={disabled}
defaultOpen={defaultOpen}
renderValue={(opt) => (
<>
<span className={!opt ? "text-t3" : undefined}>

View File

@@ -5,15 +5,11 @@ import * as React from "react";
import SmallSpinner from "@/components/general/SmallSpinner";
import { cn } from "@/lib/utils";
// hover:border-primary
// focus-visible:bg-active-primary focus-visible:border-primary
// active:bg-active-primary active:border-primary
// Remove ring styles
// focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive
const buttonVariants = cva(
`inline-flex items-center justify-center gap-2 whitespace-nowrap text-sm disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none cursor-pointer
rounded-lg group/btn transition-none w-fit transition-all duration-100
rounded-lg group/btn transition-colors duration-100 w-fit transform-gpu
`,
{
variants: {
@@ -100,35 +96,6 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
) => {
const Comp = asChild ? Slot : "button";
const buttonRef = React.useRef<HTMLButtonElement>(null);
const [contentWidth, setContentWidth] = React.useState<number | null>(null);
// Combine refs using useImperativeHandle
React.useImperativeHandle(
ref,
() => buttonRef.current as HTMLButtonElement,
[],
);
React.useEffect(() => {
if (buttonRef.current && !isLoading) {
// Measure the full button width including padding
const width = buttonRef.current.offsetWidth;
setContentWidth(width);
}
}, [isLoading]);
// Measure width on mount and when loading state changes
React.useEffect(() => {
if (buttonRef.current && !isLoading) {
// Use requestAnimationFrame to ensure DOM is fully rendered
requestAnimationFrame(() => {
if (buttonRef.current) {
const width = buttonRef.current.offsetWidth;
setContentWidth(width);
}
});
}
}, [isLoading]);
const getDisableActiveStyles = () => {
if (!disableActive) return "";
@@ -166,14 +133,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
className={cn(
buttonVariants({ variant, size, className }),
getDisableActiveStyles(),
// transition && "transition-all duration-150",
)}
style={{
minWidth:
isLoading && contentWidth
? `${contentWidth + 0.2}px` // Add small buffer to prevent any slight width changes
: undefined,
}}
disabled={isLoading || props.disabled}
{...props}
>
@@ -183,7 +143,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
className={variant === "primary" ? "relative z-10" : undefined}
/>
) : variant === "primary" ? (
<span className="relative z-10 inline-flex items-center gap-2">
<span className="relative z-10 inline-flex items-center gap-2 transition-none">
{children}
</span>
) : (

View File

@@ -1,6 +1,7 @@
import { CheckIcon, ChevronDownIcon } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import type { ReactNode } from "react";
import { useState } from "react";
import { useEffect, useState } from "react";
import {
Command,
CommandEmpty,
@@ -32,6 +33,7 @@ export type SearchableSelectProps<T> = {
disabled?: boolean;
triggerClassName?: string;
contentClassName?: string;
defaultOpen?: boolean;
};
export function SearchableSelect<T>({
@@ -50,9 +52,16 @@ export function SearchableSelect<T>({
disabled = false,
triggerClassName,
contentClassName,
defaultOpen = false,
}: SearchableSelectProps<T>) {
const [open, setOpen] = useState(false);
useEffect(() => {
if (!defaultOpen) return;
const timer = setTimeout(() => setOpen(true), 200);
return () => clearTimeout(timer);
}, [defaultOpen]);
const selectedOption = options.find((opt) => getOptionValue(opt) === value);
const handleSelect = (option: T) => {
@@ -104,64 +113,78 @@ export function SearchableSelect<T>({
<ChevronDownIcon className="size-4 shrink-0 opacity-50" />
</button>
</PopoverTrigger>
<PopoverContent
align="start"
className={cn(
"w-(--radix-popover-trigger-width) p-0 z-200 rounded-md overflow-hidden",
contentClassName,
)}
>
<Command
className="bg-interactive-secondary"
filter={
searchable
? (optionValue, search) => {
const option = options.find(
(opt) => getOptionValue(opt) === optionValue,
);
if (!option) return 0;
const searchLower = search.toLowerCase();
const labelMatch = getOptionLabel(option)
.toLowerCase()
.includes(searchLower);
const valueMatch = optionValue
.toLowerCase()
.includes(searchLower);
return labelMatch || valueMatch ? 1 : 0;
<AnimatePresence>
{open && (
<PopoverContent
forceMount
align="start"
className={cn(
"w-(--radix-popover-trigger-width) p-0 z-200 rounded-md overflow-hidden",
contentClassName,
)}
asChild
>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3 }}
>
<Command
className="bg-interactive-secondary"
filter={
searchable
? (optionValue, search) => {
const option = options.find(
(opt) => getOptionValue(opt) === optionValue,
);
if (!option) return 0;
const searchLower = search.toLowerCase();
const labelMatch = getOptionLabel(option)
.toLowerCase()
.includes(searchLower);
const valueMatch = optionValue
.toLowerCase()
.includes(searchLower);
return labelMatch || valueMatch ? 1 : 0;
}
: undefined
}
: undefined
}
>
{searchable && <CommandInput placeholder={searchPlaceholder} />}
<CommandList>
<CommandEmpty className="text-t3">{emptyText}</CommandEmpty>
<CommandGroup>
{options.map((option) => {
const optionValue = getOptionValue(option);
const isSelected = optionValue === value;
const isDisabled = getOptionDisabled?.(option) ?? false;
>
{searchable && <CommandInput placeholder={searchPlaceholder} />}
<CommandList>
<CommandEmpty className="text-t3">{emptyText}</CommandEmpty>
<CommandGroup>
{options.map((option) => {
const optionValue = getOptionValue(option);
const isSelected = optionValue === value;
const isDisabled = getOptionDisabled?.(option) ?? false;
return (
<CommandItem
key={optionValue}
value={optionValue}
onSelect={() => handleSelect(option)}
disabled={isDisabled}
className={cn(
"min-w-0",
isDisabled && "text-t4 pointer-events-none opacity-50",
)}
>
{renderOption
? renderOption(option, isSelected)
: defaultRenderOption(option, isSelected)}
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
return (
<CommandItem
key={optionValue}
value={optionValue}
onSelect={() => handleSelect(option)}
disabled={isDisabled}
className={cn(
"min-w-0",
isDisabled &&
"text-t4 pointer-events-none opacity-50",
)}
>
{renderOption
? renderOption(option, isSelected)
: defaultRenderOption(option, isSelected)}
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</motion.div>
</PopoverContent>
)}
</AnimatePresence>
</Popover>
);
}

View File

@@ -1,7 +1,6 @@
import { CaretRightIcon } from "@phosphor-icons/react";
import { motion } from "motion/react";
import { useId } from "react";
import { LAYOUT_TRANSITION as ANIM_LAYOUT_TRANSITION } from "@/components/forms/update-subscription-v2/constants/animationConstants";
import { Separator } from "@/components/v2/separator";
import { type SheetType, useSheetStore } from "@/hooks/stores/useSheetStore";
import { cn } from "@/lib/utils";
@@ -122,7 +121,11 @@ export function SheetSection({
{children}
</div>
{withSeparator && (
<motion.div layout transition={ANIM_LAYOUT_TRANSITION} className="px-4">
<motion.div
layout
transition={{ layout: LAYOUT_TRANSITION }}
className="px-4"
>
<Separator />
</motion.div>
)}

View File

@@ -0,0 +1,26 @@
import type { ProductV2 } from "@autumn/shared";
import { useQuery } from "@tanstack/react-query";
import { useAxiosInstance } from "@/services/useAxiosInstance";
/** Fetches product data for a specific version (or latest if version is omitted). */
export function useProductVersionQuery({
productId,
version,
enabled,
}: {
productId: string | undefined;
version?: number;
enabled?: boolean;
}) {
const axiosInstance = useAxiosInstance();
return useQuery({
queryKey: ["product-version", productId, version],
queryFn: async () => {
const { data } = await axiosInstance.get(`/products/${productId}/data`, {
params: version ? { version } : undefined,
});
return data as { product: ProductV2; numVersions: number };
},
enabled: enabled !== false && !!productId,
});
}

View File

@@ -1,5 +1,6 @@
import type { Entity, FullCustomer } from "@autumn/shared";
import {
AttachAdvancedSection,
AttachFooter,
AttachFormProvider,
AttachPlanSection,
@@ -75,8 +76,9 @@ function SheetContent() {
{hasProductSelected && (
<>
<AttachUpdatesSection />
<AttachPlanSection />
<AttachAdvancedSection />
<AttachUpdatesSection />
<AttachPreviewSection />
<AttachFooter />
</>

View File

@@ -29,6 +29,7 @@ import { IconButton } from "@/components/v2/buttons/IconButton";
import { InfoRow } from "@/components/v2/InfoRow";
import { SheetHeader, SheetSection } from "@/components/v2/sheets/InlineSheet";
import { useOrgStripeQuery } from "@/hooks/queries/useOrgStripeQuery";
import { useProductVersionQuery } from "@/hooks/queries/useProductVersionQuery";
import {
usePrepaidItems,
useProductStore,
@@ -64,6 +65,10 @@ export function SubscriptionDetailSheet() {
// Get customer product and productV2 by itemId
const { cusProduct, productV2 } = useSubscriptionById({ itemId });
// Prefetch product version data so the update sheet has it cached immediately
useProductVersionQuery({ productId: productV2?.id });
const isExpired = cusProduct?.status === CusProductStatus.Expired;
const isCanceled = cusProduct?.canceled;

View File

@@ -1,5 +1,4 @@
import type { FullCusProduct, ProductItem, ProductV2 } from "@autumn/shared";
import { useQuery } from "@tanstack/react-query";
import { useMemo } from "react";
import {
@@ -16,11 +15,11 @@ import {
SheetHeader,
} from "@/components/v2/sheets/SharedSheetComponents";
import { useOrgStripeQuery } from "@/hooks/queries/useOrgStripeQuery";
import { useProductVersionQuery } from "@/hooks/queries/useProductVersionQuery";
import { usePrepaidItems } from "@/hooks/stores/useProductStore";
import { useSheetStore } from "@/hooks/stores/useSheetStore";
import { useSubscriptionById } from "@/hooks/stores/useSubscriptionStore";
import { cn } from "@/lib/utils";
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { useEnv } from "@/utils/envUtils";
import { getStripeInvoiceLink } from "@/utils/linkUtils";
@@ -99,7 +98,6 @@ export function SubscriptionUpdateSheet2() {
const itemId = useSheetStore((s) => s.itemId);
const { closeSheet } = useSheetStore();
const { customer } = useCusQuery();
const axiosInstance = useAxiosInstance();
const { stripeAccount } = useOrgStripeQuery();
const env = useEnv();
const { setIsInlineEditorOpen } = useCustomerContext();
@@ -107,16 +105,8 @@ export function SubscriptionUpdateSheet2() {
const { cusProduct, productV2 } = useSubscriptionById({ itemId });
const { prepaidItems } = usePrepaidItems({ product: productV2 });
const { data: productData } = useQuery({
queryKey: ["product-versions", productV2?.id],
queryFn: async () => {
if (!productV2?.id) return null;
const { data } = await axiosInstance.get(
`/products/${productV2.id}/data`,
);
return data;
},
enabled: !!productV2?.id,
const { data: productData } = useProductVersionQuery({
productId: productV2?.id,
});
const numVersions = productData?.numVersions ?? productV2?.version ?? 1;

View File

@@ -19,7 +19,7 @@ export function AttachProductSheetTrigger() {
const feature = features.features.find((f) => f.id === entity?.feature_id);
const handleClick = () => {
setSheet({ type: "attach-product" });
setSheet({ type: "attach-product-v2" });
};
return (
<Button

View File

@@ -1,5 +1,6 @@
"use client";
import { AppEnv } from "@autumn/shared";
import { AnimatePresence, motion } from "motion/react";
import { useState } from "react";
import { createPortal } from "react-dom";
@@ -7,9 +8,11 @@ import { Link } from "react-router";
import { useHasChanges } from "@/hooks/stores/useProductStore";
import { useSheetStore } from "@/hooks/stores/useSheetStore";
import { useEntity } from "@/hooks/stores/useSubscriptionStore";
import { useEnv } from "@/utils/envUtils";
import { pushPage } from "@/utils/genUtils";
import ErrorScreen from "@/views/general/ErrorScreen";
import LoadingScreen from "@/views/general/LoadingScreen";
import { useOnboardingVisibility } from "@/views/onboarding4/hooks/useOnboardingProgress";
import { OnboardingGuide } from "@/views/onboarding4/OnboardingGuide";
import { useCusQuery } from "../../customers/customer/hooks/useCusQuery";
import { useCusReferralQuery } from "../../customers/customer/hooks/useCusReferralQuery";
@@ -37,6 +40,10 @@ export default function CustomerView2() {
const hasCustomizedProduct = !!sheetData?.customizedProduct;
const [isInlineEditorOpen, setIsInlineEditorOpen] = useState(false);
const env = useEnv();
const { isDismissed } = useOnboardingVisibility();
const showOnboarding = env === AppEnv.Sandbox && !isDismissed;
// useSheetCleanup();
if (cusLoading) return <LoadingScreen />;
@@ -74,11 +81,16 @@ export default function CustomerView2() {
transition={SHEET_ANIMATION}
>
<div className="flex flex-col overflow-x-hidden overflow-y-auto absolute inset-0 pb-8">
<div className="w-full max-w-5xl mx-auto pt-8 pb-6 px-10">
<OnboardingGuide />
</div>
{/* Rest of content shrinks normally with the container */}
<div className="flex flex-col gap-4 w-full max-w-5xl mx-auto pt-4 px-10">
{/* Onboarding Guide - only render wrapper when visible */}
{showOnboarding && (
<div className="w-full max-w-5xl mx-auto pt-8 px-10">
<OnboardingGuide />
</div>
)}
{/* Rest of content */}
<div
className={`flex flex-col gap-4 w-full max-w-5xl mx-auto px-10 ${showOnboarding ? "pt-4" : "pt-8"}`}
>
<div className="flex flex-col gap-2 w-full">
<div className="flex flex-col w-full">
<div className="flex items-center justify-between w-full gap-4">