diff --git a/server/src/external/stripe/coupons/index.ts b/server/src/external/stripe/coupons/index.ts new file mode 100644 index 000000000..22282b194 --- /dev/null +++ b/server/src/external/stripe/coupons/index.ts @@ -0,0 +1,2 @@ +export { resolveCoupon } from "./resolveCoupon"; +export { resolvePromotionCode } from "./resolvePromotionCode"; diff --git a/server/src/external/stripe/coupons/resolveCoupon.ts b/server/src/external/stripe/coupons/resolveCoupon.ts new file mode 100644 index 000000000..4f895cd56 --- /dev/null +++ b/server/src/external/stripe/coupons/resolveCoupon.ts @@ -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 => { + 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}"`, + }); + } +}; diff --git a/server/src/external/stripe/coupons/resolvePromotionCode.ts b/server/src/external/stripe/coupons/resolvePromotionCode.ts new file mode 100644 index 000000000..d2846b89a --- /dev/null +++ b/server/src/external/stripe/coupons/resolvePromotionCode.ts @@ -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 => { + 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}"`, + }); + } +}; diff --git a/server/src/external/stripe/createStripePrice/createStripePrice.ts b/server/src/external/stripe/createStripePrice/createStripePrice.ts index f4db50656..1cf4b9a97 100644 --- a/server/src/external/stripe/createStripePrice/createStripePrice.ts +++ b/server/src/external/stripe/createStripePrice/createStripePrice.ts @@ -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, }); } diff --git a/server/src/external/stripe/subscriptions/types/stripeDiscountTypes.ts b/server/src/external/stripe/subscriptions/types/stripeDiscountTypes.ts index 88c601d84..0af1b7e88 100644 --- a/server/src/external/stripe/subscriptions/types/stripeDiscountTypes.ts +++ b/server/src/external/stripe/subscriptions/types/stripeDiscountTypes.ts @@ -30,7 +30,7 @@ export type StripeCustomerExpandedDiscount = Omit & { /** * 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; diff --git a/server/src/external/stripe/webhookHandlers/common/eventContextToArrearLineItems.ts b/server/src/external/stripe/webhookHandlers/common/eventContextToArrearLineItems.ts index 866e89093..bf2662467 100644 --- a/server/src/external/stripe/webhookHandlers/common/eventContextToArrearLineItems.ts +++ b/server/src/external/stripe/webhookHandlers/common/eventContextToArrearLineItems.ts @@ -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, }); diff --git a/server/src/internal/billing/v2/actions/attach/setup/setupAttachBillingContext.ts b/server/src/internal/billing/v2/actions/attach/setup/setupAttachBillingContext.ts index 597eceacd..7a7e75a3a 100644 --- a/server/src/internal/billing/v2/actions/attach/setup/setupAttachBillingContext.ts +++ b/server/src/internal/billing/v2/actions/attach/setup/setupAttachBillingContext.ts @@ -72,6 +72,7 @@ export const setupAttachBillingContext = async ({ product: attachProduct, targetCustomerProduct: currentCustomerProduct, contextOverride, + paramDiscounts: params.discounts, }); const featureQuantities = setupFeatureQuantitiesContext({ diff --git a/server/src/internal/billing/v2/actions/legacy/utils/attachParamsToStripeBillingContext.ts b/server/src/internal/billing/v2/actions/legacy/utils/attachParamsToStripeBillingContext.ts index 524089e2a..a29843796 100644 --- a/server/src/internal/billing/v2/actions/legacy/utils/attachParamsToStripeBillingContext.ts +++ b/server/src/internal/billing/v2/actions/legacy/utils/attachParamsToStripeBillingContext.ts @@ -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, }); diff --git a/server/src/internal/billing/v2/providers/stripe/actionBuilders/buildStripeCheckoutSessionAction.ts b/server/src/internal/billing/v2/providers/stripe/actionBuilders/buildStripeCheckoutSessionAction.ts index e3494b5a3..cfeb7fa65 100644 --- a/server/src/internal/billing/v2/providers/stripe/actionBuilders/buildStripeCheckoutSessionAction.ts +++ b/server/src/internal/billing/v2/providers/stripe/actionBuilders/buildStripeCheckoutSessionAction.ts @@ -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 }; diff --git a/server/src/internal/billing/v2/providers/stripe/execute/executeStripeCheckoutSessionAction.ts b/server/src/internal/billing/v2/providers/stripe/execute/executeStripeCheckoutSessionAction.ts index 4fa19f8a8..71d47d31c 100644 --- a/server/src/internal/billing/v2/providers/stripe/execute/executeStripeCheckoutSessionAction.ts +++ b/server/src/internal/billing/v2/providers/stripe/execute/executeStripeCheckoutSessionAction.ts @@ -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" diff --git a/server/src/internal/billing/v2/providers/stripe/setup/fetchStripeDiscountsForBilling.ts b/server/src/internal/billing/v2/providers/stripe/setup/fetchStripeDiscountsForBilling.ts new file mode 100644 index 000000000..b2056b72a --- /dev/null +++ b/server/src/internal/billing/v2/providers/stripe/setup/fetchStripeDiscountsForBilling.ts @@ -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 => { + 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]; +}; \ No newline at end of file diff --git a/server/src/internal/billing/v2/providers/stripe/setup/setupStripeBillingContext.ts b/server/src/internal/billing/v2/providers/stripe/setup/setupStripeBillingContext.ts index 921559c45..d7130296c 100644 --- a/server/src/internal/billing/v2/providers/stripe/setup/setupStripeBillingContext.ts +++ b/server/src/internal/billing/v2/providers/stripe/setup/setupStripeBillingContext.ts @@ -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 { diff --git a/server/src/internal/billing/v2/providers/stripe/setup/setupStripeDiscountsForBilling.ts b/server/src/internal/billing/v2/providers/stripe/setup/setupStripeDiscountsForBilling.ts deleted file mode 100644 index 8a479ddc5..000000000 --- a/server/src/internal/billing/v2/providers/stripe/setup/setupStripeDiscountsForBilling.ts +++ /dev/null @@ -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]; -}; diff --git a/server/src/internal/billing/v2/providers/stripe/utils/discounts/resolveParamDiscounts.ts b/server/src/internal/billing/v2/providers/stripe/utils/discounts/resolveParamDiscounts.ts new file mode 100644 index 000000000..484ce0855 --- /dev/null +++ b/server/src/internal/billing/v2/providers/stripe/utils/discounts/resolveParamDiscounts.ts @@ -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 => { + 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(); + return resolved.filter((d) => { + const couponId = d.source.coupon.id; + if (seen.has(couponId)) return false; + seen.add(couponId); + return true; + }); +}; diff --git a/server/src/internal/billing/v2/providers/stripe/utils/discounts/stripeDiscountsToParams.ts b/server/src/internal/billing/v2/providers/stripe/utils/discounts/stripeDiscountsToParams.ts new file mode 100644 index 000000000..3a0ad9517 --- /dev/null +++ b/server/src/internal/billing/v2/providers/stripe/utils/discounts/stripeDiscountsToParams.ts @@ -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 }, + ); +}; diff --git a/server/src/internal/billing/v2/providers/stripe/utils/subscriptionSchedules/buildStripePhasesUpdate.ts b/server/src/internal/billing/v2/providers/stripe/utils/subscriptionSchedules/buildStripePhasesUpdate.ts index 34dc23cca..4942e9ce4 100644 --- a/server/src/internal/billing/v2/providers/stripe/utils/subscriptionSchedules/buildStripePhasesUpdate.ts +++ b/server/src/internal/billing/v2/providers/stripe/utils/subscriptionSchedules/buildStripePhasesUpdate.ts @@ -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 diff --git a/server/src/internal/billing/v2/providers/stripe/utils/subscriptions/buildStripeSubscriptionCreateAction.ts b/server/src/internal/billing/v2/providers/stripe/utils/subscriptions/buildStripeSubscriptionCreateAction.ts index c5848eb5f..7aa07f23f 100644 --- a/server/src/internal/billing/v2/providers/stripe/utils/subscriptions/buildStripeSubscriptionCreateAction.ts +++ b/server/src/internal/billing/v2/providers/stripe/utils/subscriptions/buildStripeSubscriptionCreateAction.ts @@ -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: { diff --git a/server/src/internal/billing/v2/providers/stripe/utils/subscriptions/buildStripeSubscriptionUpdateAction.ts b/server/src/internal/billing/v2/providers/stripe/utils/subscriptions/buildStripeSubscriptionUpdateAction.ts index 0b92b0908..d43165af7 100644 --- a/server/src/internal/billing/v2/providers/stripe/utils/subscriptions/buildStripeSubscriptionUpdateAction.ts +++ b/server/src/internal/billing/v2/providers/stripe/utils/subscriptions/buildStripeSubscriptionUpdateAction.ts @@ -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; diff --git a/server/tests/integration/billing/attach/discounts/attach-discounts-basic.test.ts b/server/tests/integration/billing/attach/discounts/attach-discounts-basic.test.ts new file mode 100644 index 000000000..365264598 --- /dev/null +++ b/server/tests/integration/billing/attach/discounts/attach-discounts-basic.test.ts @@ -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(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(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(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(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(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(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(customerId); + + // Invoice total matches preview + await expectCustomerInvoiceCorrect({ + customer, + count: 1, + latestTotal: preview.total, + }); +}); diff --git a/server/tests/integration/billing/attach/discounts/attach-discounts-errors.test.ts b/server/tests/integration/billing/attach/discounts/attach-discounts-errors.test.ts new file mode 100644 index 000000000..970351925 --- /dev/null +++ b/server/tests/integration/billing/attach/discounts/attach-discounts-errors.test.ts @@ -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" }], + }); + }, + }); +}); diff --git a/server/tests/integration/billing/attach/discounts/attach-discounts-stacking.test.ts b/server/tests/integration/billing/attach/discounts/attach-discounts-stacking.test.ts new file mode 100644 index 000000000..6b5d556e9 --- /dev/null +++ b/server/tests/integration/billing/attach/discounts/attach-discounts-stacking.test.ts @@ -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(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(customerId); + + await expectCustomerProducts({ + customer, + active: [pro.id], + notPresent: [free.id], + }); +}); diff --git a/server/tests/integration/billing/attach/free-trial/trial-entity-upgrade.test.ts b/server/tests/integration/billing/attach/free-trial/trial-entity-upgrade.test.ts index 8576ffb38..000263dca 100644 --- a/server/tests/integration/billing/attach/free-trial/trial-entity-upgrade.test.ts +++ b/server/tests/integration/billing/attach/free-trial/trial-entity-upgrade.test.ts @@ -787,8 +787,8 @@ test.concurrent(`${chalk.yellowBright("trial-entity-upgrade 5: both entities upg const customer = await autumnV1.customers.get(customerId); await expectCustomerInvoiceCorrect({ customer, - latestTotal: 0, count: 4, + latestTotal: 0, }); // Verify Stripe subscription state diff --git a/server/tests/integration/billing/utils/discounts/discountTestUtils.ts b/server/tests/integration/billing/utils/discounts/discountTestUtils.ts index 822488092..0a3a2b876 100644 --- a/server/tests/integration/billing/utils/discounts/discountTestUtils.ts +++ b/server/tests/integration/billing/utils/discounts/discountTestUtils.ts @@ -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()}`, + }); +}; diff --git a/server/tests/scenarios/update-subscription/multi-version-scenario.test.ts b/server/tests/scenarios/update-subscription/multi-version-scenario.test.ts new file mode 100644 index 000000000..3161811bb --- /dev/null +++ b/server/tests/scenarios/update-subscription/multi-version-scenario.test.ts @@ -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, + // }); +}); diff --git a/server/tests/unit/billing/stripe/discounts/setup-stripe-discounts-for-billing.spec.ts b/server/tests/unit/billing/stripe/discounts/setup-stripe-discounts-for-billing.spec.ts deleted file mode 100644 index c810bfb3e..000000000 --- a/server/tests/unit/billing/stripe/discounts/setup-stripe-discounts-for-billing.spec.ts +++ /dev/null @@ -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"]); - }); - }); -}); diff --git a/server/tests/utils/fixtures/db/discounts.ts b/server/tests/utils/fixtures/db/discounts.ts index c184e0cfe..190829411 100644 --- a/server/tests/utils/fixtures/db/discounts.ts +++ b/server/tests/utils/fixtures/db/discounts.ts @@ -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 // ═══════════════════════════════════════════════════════════════════ diff --git a/server/tests/utils/fixtures/stripe/subscriptions.ts b/server/tests/utils/fixtures/stripe/subscriptions.ts index 52872df91..72c32d734 100644 --- a/server/tests/utils/fixtures/stripe/subscriptions.ts +++ b/server/tests/utils/fixtures/stripe/subscriptions.ts @@ -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({ diff --git a/shared/api/billing/attachV2/attachDiscount.ts b/shared/api/billing/attachV2/attachDiscount.ts new file mode 100644 index 000000000..6b604f584 --- /dev/null +++ b/shared/api/billing/attachV2/attachDiscount.ts @@ -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; diff --git a/shared/api/billing/attachV2/attachParamsV0.ts b/shared/api/billing/attachV2/attachParamsV0.ts index b15cbc32b..2c842dd83 100644 --- a/shared/api/billing/attachV2/attachParamsV0.ts +++ b/shared/api/billing/attachV2/attachParamsV0.ts @@ -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; @@ -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 diff --git a/shared/api/billing/index.ts b/shared/api/billing/index.ts index 711d76b27..132427fa1 100644 --- a/shared/api/billing/index.ts +++ b/shared/api/billing/index.ts @@ -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 diff --git a/shared/index.ts b/shared/index.ts index 3b03c596d..8dde62439 100644 --- a/shared/index.ts +++ b/shared/index.ts @@ -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"; diff --git a/shared/models/billingModels/stripe/stripeDiscountWithCoupon.ts b/shared/models/billingModels/stripe/stripeDiscountWithCoupon.ts index 427ee2ec5..4324527ef 100644 --- a/shared/models/billingModels/stripe/stripeDiscountWithCoupon.ts +++ b/shared/models/billingModels/stripe/stripeDiscountWithCoupon.ts @@ -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; }; diff --git a/shared/utils/productUtils/priceUtils/convertPrice/priceToStripePrepaidV2Tiers.ts b/shared/utils/productUtils/priceUtils/convertPrice/priceToStripePrepaidV2Tiers.ts index 5d41b53e8..9a190010e 100644 --- a/shared/utils/productUtils/priceUtils/convertPrice/priceToStripePrepaidV2Tiers.ts +++ b/shared/utils/productUtils/priceUtils/convertPrice/priceToStripePrepaidV2Tiers.ts @@ -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; diff --git a/shared/utils/rewardUtils/rewardFilterUtils.ts b/shared/utils/rewardUtils/rewardFilterUtils.ts new file mode 100644 index 000000000..4adc35595 --- /dev/null +++ b/shared/utils/rewardUtils/rewardFilterUtils.ts @@ -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 }), + ); +}; diff --git a/vite/src/components/forms/attach-v2/attachFormSchema.ts b/vite/src/components/forms/attach-v2/attachFormSchema.ts index da522e31b..68cd87b33 100644 --- a/vite/src/components/forms/attach-v2/attachFormSchema.ts +++ b/vite/src/components/forms/attach-v2/attachFormSchema.ts @@ -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().nullable(), + discounts: z.custom(), }); export type AttachForm = z.infer; diff --git a/vite/src/components/forms/attach-v2/components/AttachAdvancedSection.tsx b/vite/src/components/forms/attach-v2/components/AttachAdvancedSection.tsx new file mode 100644 index 000000000..354fdd626 --- /dev/null +++ b/vite/src/components/forms/attach-v2/components/AttachAdvancedSection.tsx @@ -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 ( + + + + + + + + {isOpen && ( + + + {/* Plan Schedule */} + +
+ Plan Schedule +
+ } + iconOrientation="left" + variant="secondary" + size="sm" + checked={isImmediateSelected} + onCheckedChange={() => + handleScheduleChange("immediate") + } + className={cn( + "rounded-r-none", + !isImmediateSelected && "border-r-0", + )} + > + Immediately + + } + 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 + +
+
+
+ + {/* Discounts */} + +
+
+ Discounts + } + className="text-t3" + > + Add + +
+ {discounts.length > 0 && ( +
+ + {discounts.map((discount, index) => ( + + + + ))} + +
+ )} +
+
+
+
+ )} +
+
+
+ ); +} diff --git a/vite/src/components/forms/attach-v2/components/AttachDiscountRow.tsx b/vite/src/components/forms/attach-v2/components/AttachDiscountRow.tsx new file mode 100644 index 000000000..39b21296c --- /dev/null +++ b/vite/src/components/forms/attach-v2/components/AttachDiscountRow.tsx @@ -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 ( +
+ {/* Reward select */} +
+ 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) => ( + <> + + {reward.name || reward.id} + + {reward.promo_codes?.[0]?.code && ( + + {reward.promo_codes[0].code} + + )} + {isSelected && } + + )} + renderValue={(reward) => { + if (!reward) + return Select reward...; + return ( + + {reward.name || reward.id} + {reward.promo_codes?.[0]?.code && ( + + {reward.promo_codes[0].code} + + )} + + ); + }} + /> +
+ + {/* Remove button */} + } + className="shrink-0 text-t3 hover:text-red-500" + /> +
+ ); +} diff --git a/vite/src/components/forms/attach-v2/components/AttachPlanSection.tsx b/vite/src/components/forms/attach-v2/components/AttachPlanSection.tsx index 4c69b5db2..cabf58c2a 100644 --- a/vite/src/components/forms/attach-v2/components/AttachPlanSection.tsx +++ b/vite/src/components/forms/attach-v2/components/AttachPlanSection.tsx @@ -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} > - +

diff --git a/vite/src/components/forms/attach-v2/components/AttachPlanSkeleton.tsx b/vite/src/components/forms/attach-v2/components/AttachPlanSkeleton.tsx index a41ce415b..03bd75812 100644 --- a/vite/src/components/forms/attach-v2/components/AttachPlanSkeleton.tsx +++ b/vite/src/components/forms/attach-v2/components/AttachPlanSkeleton.tsx @@ -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() { Plan Configuration - - } - variant="secondary" - className="h-7 whitespace-nowrap" - disabled - > - Settings - - } - variant="secondary" - className="h-7 whitespace-nowrap" - disabled - > - Free Trial - - + } + variant="secondary" + className="h-7 whitespace-nowrap" + disabled + > + Free Trial +
diff --git a/vite/src/components/forms/attach-v2/components/AttachPreviewSection.tsx b/vite/src/components/forms/attach-v2/components/AttachPreviewSection.tsx index a1bd3aabe..e7a4e29c4 100644 --- a/vite/src/components/forms/attach-v2/components/AttachPreviewSection.tsx +++ b/vite/src/components/forms/attach-v2/components/AttachPreviewSection.tsx @@ -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 ( - + @@ -52,7 +54,7 @@ export function AttachPreviewSection() { } return ( - + ({ label: p.name, value: p.id, diff --git a/vite/src/components/forms/attach-v2/components/AttachSectionTitle.tsx b/vite/src/components/forms/attach-v2/components/AttachSectionTitle.tsx index a32f786f6..22554235e 100644 --- a/vite/src/components/forms/attach-v2/components/AttachSectionTitle.tsx +++ b/vite/src/components/forms/attach-v2/components/AttachSectionTitle.tsx @@ -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() { )} - - - - - - } - 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 - - - - {trialIsActive - ? "Trial configured - click to edit" - : "Add a free trial"} - - - + + + + } + 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 + + + + {trialIsActive + ? "Trial configured - click to edit" + : "Add a free trial"} + + ); } diff --git a/vite/src/components/forms/attach-v2/components/AttachUpdatesSection.tsx b/vite/src/components/forms/attach-v2/components/AttachUpdatesSection.tsx index 5bd7b12b0..867d314a7 100644 --- a/vite/src/components/forms/attach-v2/components/AttachUpdatesSection.tsx +++ b/vite/src/components/forms/attach-v2/components/AttachUpdatesSection.tsx @@ -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 ( - + + - + Attaching{" "} { 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( diff --git a/vite/src/components/forms/attach-v2/index.ts b/vite/src/components/forms/attach-v2/index.ts index 8283959ad..32ecde4e4 100644 --- a/vite/src/components/forms/attach-v2/index.ts +++ b/vite/src/components/forms/attach-v2/index.ts @@ -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"; diff --git a/vite/src/components/forms/attach-v2/utils/discountUtils.ts b/vite/src/components/forms/attach-v2/utils/discountUtils.ts new file mode 100644 index 000000000..472e73acb --- /dev/null +++ b/vite/src/components/forms/attach-v2/utils/discountUtils.ts @@ -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; + }), + ); +}; diff --git a/vite/src/components/forms/shared/PlanItemsSection.tsx b/vite/src/components/forms/shared/PlanItemsSection.tsx index 3627c7d58..6edb8cb9e 100644 --- a/vite/src/components/forms/shared/PlanItemsSection.tsx +++ b/vite/src/components/forms/shared/PlanItemsSection.tsx @@ -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 ( ( @@ -229,9 +230,9 @@ export function PlanItemsSection({ return ( ( - - { - 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; + + {open && ( + + + { + 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 && } - - {emptyText} - - {options.map((option) => { - const optionValue = getOptionValue(option); - const isSelected = optionValue === value; - const isDisabled = getOptionDisabled?.(option) ?? false; + > + {searchable && } + + {emptyText} + + {options.map((option) => { + const optionValue = getOptionValue(option); + const isSelected = optionValue === value; + const isDisabled = getOptionDisabled?.(option) ?? false; - return ( - handleSelect(option)} - disabled={isDisabled} - className={cn( - "min-w-0", - isDisabled && "text-t4 pointer-events-none opacity-50", - )} - > - {renderOption - ? renderOption(option, isSelected) - : defaultRenderOption(option, isSelected)} - - ); - })} - - - - + return ( + handleSelect(option)} + disabled={isDisabled} + className={cn( + "min-w-0", + isDisabled && + "text-t4 pointer-events-none opacity-50", + )} + > + {renderOption + ? renderOption(option, isSelected) + : defaultRenderOption(option, isSelected)} + + ); + })} + + + + + + )} + ); } diff --git a/vite/src/components/v2/sheets/SharedSheetComponents.tsx b/vite/src/components/v2/sheets/SharedSheetComponents.tsx index 818140f68..a30fac2bf 100644 --- a/vite/src/components/v2/sheets/SharedSheetComponents.tsx +++ b/vite/src/components/v2/sheets/SharedSheetComponents.tsx @@ -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} {withSeparator && ( - + )} diff --git a/vite/src/hooks/queries/useProductVersionQuery.tsx b/vite/src/hooks/queries/useProductVersionQuery.tsx new file mode 100644 index 000000000..35b8ac302 --- /dev/null +++ b/vite/src/hooks/queries/useProductVersionQuery.tsx @@ -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, + }); +} diff --git a/vite/src/views/customers2/components/sheets/AttachProductSheetV2.tsx b/vite/src/views/customers2/components/sheets/AttachProductSheetV2.tsx index 78486eed6..140016791 100644 --- a/vite/src/views/customers2/components/sheets/AttachProductSheetV2.tsx +++ b/vite/src/views/customers2/components/sheets/AttachProductSheetV2.tsx @@ -1,5 +1,6 @@ import type { Entity, FullCustomer } from "@autumn/shared"; import { + AttachAdvancedSection, AttachFooter, AttachFormProvider, AttachPlanSection, @@ -75,8 +76,9 @@ function SheetContent() { {hasProductSelected && ( <> - + + diff --git a/vite/src/views/customers2/components/sheets/SubscriptionDetailSheet.tsx b/vite/src/views/customers2/components/sheets/SubscriptionDetailSheet.tsx index a66073397..18f37fcaf 100644 --- a/vite/src/views/customers2/components/sheets/SubscriptionDetailSheet.tsx +++ b/vite/src/views/customers2/components/sheets/SubscriptionDetailSheet.tsx @@ -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; diff --git a/vite/src/views/customers2/components/sheets/SubscriptionUpdateSheet2.tsx b/vite/src/views/customers2/components/sheets/SubscriptionUpdateSheet2.tsx index 7879ffbab..5727c4ebf 100644 --- a/vite/src/views/customers2/components/sheets/SubscriptionUpdateSheet2.tsx +++ b/vite/src/views/customers2/components/sheets/SubscriptionUpdateSheet2.tsx @@ -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; diff --git a/vite/src/views/customers2/components/table/customer-products/AttachProductSheetTrigger.tsx b/vite/src/views/customers2/components/table/customer-products/AttachProductSheetTrigger.tsx index 4cf6cdeb8..8461ced71 100644 --- a/vite/src/views/customers2/components/table/customer-products/AttachProductSheetTrigger.tsx +++ b/vite/src/views/customers2/components/table/customer-products/AttachProductSheetTrigger.tsx @@ -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 (