Merge branch 'charlie/attach-v2' of https://github.com/useautumn/autumn into charlie/attach-v2
This commit is contained in:
2
server/src/external/stripe/coupons/index.ts
vendored
Normal file
2
server/src/external/stripe/coupons/index.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
export { resolveCoupon } from "./resolveCoupon";
|
||||
export { resolvePromotionCode } from "./resolvePromotionCode";
|
||||
33
server/src/external/stripe/coupons/resolveCoupon.ts
vendored
Normal file
33
server/src/external/stripe/coupons/resolveCoupon.ts
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { StripeDiscountWithCoupon } from "@autumn/shared";
|
||||
import { RecaseError } from "@autumn/shared";
|
||||
import type Stripe from "stripe";
|
||||
|
||||
/**
|
||||
* Retrieves and validates a Stripe coupon by its ID.
|
||||
* Returns a StripeDiscountWithCoupon if valid, throws RecaseError if invalid or not found.
|
||||
*/
|
||||
export const resolveCoupon = async ({
|
||||
stripeCli,
|
||||
couponId,
|
||||
}: {
|
||||
stripeCli: Stripe;
|
||||
couponId: string;
|
||||
}): Promise<StripeDiscountWithCoupon> => {
|
||||
try {
|
||||
const coupon = await stripeCli.coupons.retrieve(couponId);
|
||||
|
||||
if (!coupon.valid) {
|
||||
throw new RecaseError({
|
||||
message: `Coupon "${couponId}" is no longer valid`,
|
||||
});
|
||||
}
|
||||
|
||||
return { source: { coupon } };
|
||||
} catch (error) {
|
||||
if (error instanceof RecaseError) throw error;
|
||||
|
||||
throw new RecaseError({
|
||||
message: `Invalid coupon ID: "${couponId}"`,
|
||||
});
|
||||
}
|
||||
};
|
||||
57
server/src/external/stripe/coupons/resolvePromotionCode.ts
vendored
Normal file
57
server/src/external/stripe/coupons/resolvePromotionCode.ts
vendored
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { StripeDiscountWithCoupon } from "@autumn/shared";
|
||||
import { RecaseError } from "@autumn/shared";
|
||||
import type Stripe from "stripe";
|
||||
|
||||
/**
|
||||
* Resolves a human-readable promotion code string to a StripeDiscountWithCoupon.
|
||||
* Validates that the promotion code exists, is active, and its coupon is valid.
|
||||
* Stores the promotion code ID for proper attribution in checkout sessions.
|
||||
*/
|
||||
export const resolvePromotionCode = async ({
|
||||
stripeCli,
|
||||
code,
|
||||
}: {
|
||||
stripeCli: Stripe;
|
||||
code: string;
|
||||
}): Promise<StripeDiscountWithCoupon> => {
|
||||
try {
|
||||
const promos = await stripeCli.promotionCodes.list({
|
||||
code,
|
||||
active: true,
|
||||
limit: 1,
|
||||
expand: ["data.promotion.coupon"],
|
||||
});
|
||||
|
||||
if (promos.data.length === 0) {
|
||||
throw new RecaseError({
|
||||
message: `Promotion code not found or inactive: "${code}"`,
|
||||
});
|
||||
}
|
||||
|
||||
const promo = promos.data[0];
|
||||
const couponRaw = promo.promotion.coupon;
|
||||
|
||||
if (!couponRaw || typeof couponRaw === "string") {
|
||||
throw new RecaseError({
|
||||
message: `Could not resolve coupon for promotion code "${code}"`,
|
||||
});
|
||||
}
|
||||
|
||||
if (!couponRaw.valid) {
|
||||
throw new RecaseError({
|
||||
message: `Coupon for promotion code "${code}" is no longer valid`,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
source: { coupon: couponRaw },
|
||||
promotionCodeId: promo.id,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof RecaseError) throw error;
|
||||
|
||||
throw new RecaseError({
|
||||
message: `Invalid promotion code: "${code}"`,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ export type StripeCustomerExpandedDiscount = Omit<Stripe.Discount, "source"> & {
|
||||
|
||||
/**
|
||||
* Stripe subscription with discounts expanded.
|
||||
* Compatible type for setupStripeDiscountsForBilling.
|
||||
* Compatible type for extractStripeDiscounts / fetchStripeDiscountsForBilling.
|
||||
*/
|
||||
export type StripeSubscriptionWithDiscounts = Stripe.Subscription & {
|
||||
discounts: StripeExpandedDiscount[];
|
||||
@@ -38,7 +38,7 @@ export type StripeSubscriptionWithDiscounts = Stripe.Subscription & {
|
||||
|
||||
/**
|
||||
* Stripe customer with discount expanded.
|
||||
* Compatible type for setupStripeDiscountsForBilling.
|
||||
* Compatible type for extractStripeDiscounts / fetchStripeDiscountsForBilling.
|
||||
*/
|
||||
export type StripeCustomerWithDiscount = Stripe.Customer & {
|
||||
discount: StripeCustomerExpandedDiscount | null;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -72,6 +72,7 @@ export const setupAttachBillingContext = async ({
|
||||
product: attachProduct,
|
||||
targetCustomerProduct: currentCustomerProduct,
|
||||
contextOverride,
|
||||
paramDiscounts: params.discounts,
|
||||
});
|
||||
|
||||
const featureQuantities = setupFeatureQuantitiesContext({
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { AttachDiscount, StripeDiscountWithCoupon } from "@autumn/shared";
|
||||
import { createStripeCli } from "@/external/connect/createStripeCli";
|
||||
import type {
|
||||
StripeCustomerWithDiscount,
|
||||
StripeSubscriptionWithDiscounts,
|
||||
} from "@/external/stripe/subscriptions";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import { resolveParamDiscounts } from "../utils/discounts/resolveParamDiscounts";
|
||||
import { subToDiscounts } from "../utils/discounts/subToDiscounts";
|
||||
|
||||
/**
|
||||
* Extracts discounts from already-fetched Stripe subscription or customer.
|
||||
* Subscription discounts take priority over customer discounts.
|
||||
*
|
||||
* Both subscription and customer discounts use the `source.coupon` structure
|
||||
* introduced in Stripe API version 2025-09-30.clover.
|
||||
*
|
||||
* @see https://docs.stripe.com/changelog/clover/2025-09-30/add-discount-source-property
|
||||
* @see https://docs.stripe.com/api/discounts/object
|
||||
*/
|
||||
export const extractStripeDiscounts = ({
|
||||
stripeSubscription,
|
||||
stripeCustomer,
|
||||
}: {
|
||||
stripeSubscription?: StripeSubscriptionWithDiscounts;
|
||||
stripeCustomer: StripeCustomerWithDiscount;
|
||||
}): StripeDiscountWithCoupon[] => {
|
||||
const subscriptionDiscounts = subToDiscounts({ sub: stripeSubscription });
|
||||
|
||||
if (subscriptionDiscounts.length > 0) {
|
||||
return subscriptionDiscounts;
|
||||
}
|
||||
|
||||
const customerDiscount = stripeCustomer.discount;
|
||||
if (!customerDiscount) return [];
|
||||
|
||||
const coupon = customerDiscount.source?.coupon;
|
||||
if (!coupon || typeof coupon === "string") return [];
|
||||
|
||||
// Customer discount already has source.coupon structure, return as-is
|
||||
return [customerDiscount as StripeDiscountWithCoupon];
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches discounts for billing, combining existing Stripe discounts with optional param discounts.
|
||||
* Resolves param discounts via Stripe API and merges with existing subscription/customer discounts.
|
||||
* Deduplicates by coupon ID.
|
||||
*/
|
||||
export const fetchStripeDiscountsForBilling = async ({
|
||||
ctx,
|
||||
stripeSubscription,
|
||||
stripeCustomer,
|
||||
paramDiscounts,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
stripeSubscription?: StripeSubscriptionWithDiscounts;
|
||||
stripeCustomer: StripeCustomerWithDiscount;
|
||||
paramDiscounts?: AttachDiscount[];
|
||||
}): Promise<StripeDiscountWithCoupon[]> => {
|
||||
const existingDiscounts = extractStripeDiscounts({
|
||||
stripeSubscription,
|
||||
stripeCustomer,
|
||||
});
|
||||
|
||||
if (!paramDiscounts?.length) {
|
||||
return existingDiscounts;
|
||||
}
|
||||
|
||||
const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env });
|
||||
const resolvedParamDiscounts = await resolveParamDiscounts({
|
||||
stripeCli,
|
||||
discounts: paramDiscounts,
|
||||
});
|
||||
|
||||
// Merge with existing discounts, deduplicating by coupon ID
|
||||
const existingCouponIds = new Set(
|
||||
existingDiscounts.map((d) => d.source.coupon.id),
|
||||
);
|
||||
const newDiscounts = resolvedParamDiscounts.filter(
|
||||
(d) => !existingCouponIds.has(d.source.coupon.id),
|
||||
);
|
||||
|
||||
return [...existingDiscounts, ...newDiscounts];
|
||||
};
|
||||
@@ -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 {
|
||||
|
||||
@@ -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];
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { AttachDiscount, StripeDiscountWithCoupon } from "@autumn/shared";
|
||||
import type Stripe from "stripe";
|
||||
import { resolveCoupon, resolvePromotionCode } from "@/external/stripe/coupons";
|
||||
|
||||
/**
|
||||
* Resolves `discounts` param entries into validated Stripe coupon objects.
|
||||
* Accepts coupon IDs (passed directly) and human-readable promo code strings (resolved via Stripe API).
|
||||
*/
|
||||
export const resolveParamDiscounts = async ({
|
||||
stripeCli,
|
||||
discounts,
|
||||
}: {
|
||||
stripeCli: Stripe;
|
||||
discounts: AttachDiscount[];
|
||||
}): Promise<StripeDiscountWithCoupon[]> => {
|
||||
const resolved = await Promise.all(
|
||||
discounts.map((discount) => {
|
||||
if ("reward_id" in discount) {
|
||||
return resolveCoupon({ stripeCli, couponId: discount.reward_id });
|
||||
}
|
||||
return resolvePromotionCode({
|
||||
stripeCli,
|
||||
code: discount.promotion_code,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
// Deduplicate by coupon ID
|
||||
const seen = new Set<string>();
|
||||
return resolved.filter((d) => {
|
||||
const couponId = d.source.coupon.id;
|
||||
if (seen.has(couponId)) return false;
|
||||
seen.add(couponId);
|
||||
return true;
|
||||
});
|
||||
};
|
||||
@@ -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 },
|
||||
);
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,497 @@
|
||||
/**
|
||||
* Integration tests for attaching products with discounts param.
|
||||
*
|
||||
* Tests basic discount scenarios:
|
||||
* - Percent-off and amount-off rewards on new subscriptions
|
||||
* - Promotion code resolution
|
||||
* - Multiple rewards stacking
|
||||
* - Duplicate reward deduplication
|
||||
* - Upgrade with discount
|
||||
* - Preview accuracy with discounts
|
||||
*/
|
||||
|
||||
import { expect, test } from "bun:test";
|
||||
import type { ApiCustomerV3 } from "@autumn/shared";
|
||||
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect.js";
|
||||
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect.js";
|
||||
import { expectCustomerProducts } from "@tests/integration/billing/utils/expectCustomerProductCorrect.js";
|
||||
import { TestFeature } from "@tests/setup/v2Features.js";
|
||||
import { items } from "@tests/utils/fixtures/items.js";
|
||||
import { products } from "@tests/utils/fixtures/products.js";
|
||||
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
|
||||
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
|
||||
import chalk from "chalk";
|
||||
import { createStripeCli } from "@/external/connect/createStripeCli.js";
|
||||
import {
|
||||
createAmountCoupon,
|
||||
createPercentCoupon,
|
||||
createPromotionCode,
|
||||
getStripeSubscription,
|
||||
} from "../../utils/discounts/discountTestUtils.js";
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 1: Free to Pro with percent-off reward
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Scenario:
|
||||
* - Customer on free product
|
||||
* - Create 20% off coupon in Stripe
|
||||
* - Attach pro ($20/mo) with discount param
|
||||
*
|
||||
* Expected:
|
||||
* - Pro active, free removed
|
||||
* - Invoice = $20 * 0.8 = $16
|
||||
*/
|
||||
test.concurrent(`${chalk.yellowBright("attach-discount 1: free to pro with percent-off reward")}`, async () => {
|
||||
const customerId = "att-disc-pct-off";
|
||||
|
||||
const free = products.base({
|
||||
id: "free",
|
||||
items: [items.monthlyMessages({ includedUsage: 100 })],
|
||||
});
|
||||
|
||||
const pro = products.pro({
|
||||
id: "pro",
|
||||
items: [items.monthlyMessages({ includedUsage: 500 })],
|
||||
});
|
||||
|
||||
const { autumnV1 } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [free, pro] }),
|
||||
],
|
||||
actions: [s.billing.attach({ productId: free.id })],
|
||||
});
|
||||
|
||||
const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env });
|
||||
const coupon = await createPercentCoupon({ stripeCli, percentOff: 20 });
|
||||
|
||||
await autumnV1.billing.attach({
|
||||
customer_id: customerId,
|
||||
product_id: pro.id,
|
||||
discounts: [{ reward_id: coupon.id }],
|
||||
});
|
||||
|
||||
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
|
||||
await expectCustomerProducts({
|
||||
customer,
|
||||
active: [pro.id],
|
||||
notPresent: [free.id],
|
||||
});
|
||||
|
||||
expectCustomerFeatureCorrect({
|
||||
customer,
|
||||
featureId: TestFeature.Messages,
|
||||
includedUsage: 500,
|
||||
balance: 500,
|
||||
usage: 0,
|
||||
});
|
||||
|
||||
await expectCustomerInvoiceCorrect({
|
||||
customer,
|
||||
count: 1,
|
||||
latestTotal: 16,
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 2: Free to Pro with amount-off reward
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Scenario:
|
||||
* - Customer on free product
|
||||
* - Create $5 off coupon in Stripe
|
||||
* - Attach pro ($20/mo) with discount param
|
||||
*
|
||||
* Expected:
|
||||
* - Pro active, free removed
|
||||
* - Invoice = $20 - $5 = $15
|
||||
*/
|
||||
test.concurrent(`${chalk.yellowBright("attach-discount 2: free to pro with amount-off reward")}`, async () => {
|
||||
const customerId = "att-disc-amt-off";
|
||||
|
||||
const free = products.base({
|
||||
id: "free",
|
||||
items: [items.monthlyMessages({ includedUsage: 100 })],
|
||||
});
|
||||
|
||||
const pro = products.pro({
|
||||
id: "pro",
|
||||
items: [items.monthlyMessages({ includedUsage: 500 })],
|
||||
});
|
||||
|
||||
const { autumnV1 } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [free, pro] }),
|
||||
],
|
||||
actions: [s.billing.attach({ productId: free.id })],
|
||||
});
|
||||
|
||||
const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env });
|
||||
const coupon = await createAmountCoupon({ stripeCli, amountOffCents: 500 });
|
||||
|
||||
await autumnV1.billing.attach({
|
||||
customer_id: customerId,
|
||||
product_id: pro.id,
|
||||
discounts: [{ reward_id: coupon.id }],
|
||||
});
|
||||
|
||||
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
|
||||
await expectCustomerProducts({
|
||||
customer,
|
||||
active: [pro.id],
|
||||
notPresent: [free.id],
|
||||
});
|
||||
|
||||
await expectCustomerInvoiceCorrect({
|
||||
customer,
|
||||
count: 1,
|
||||
latestTotal: 15,
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 3: Free to Pro with promotion code
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Scenario:
|
||||
* - Customer on free product
|
||||
* - Create 25% coupon + promotion code in Stripe
|
||||
* - Attach pro ($20/mo) with promotion_code param
|
||||
*
|
||||
* Expected:
|
||||
* - Pro active
|
||||
* - Invoice = $20 * 0.75 = $15
|
||||
*/
|
||||
test.concurrent(`${chalk.yellowBright("attach-discount 3: free to pro with promotion code")}`, async () => {
|
||||
const customerId = "att-disc-promo";
|
||||
|
||||
const free = products.base({
|
||||
id: "free",
|
||||
items: [items.monthlyMessages({ includedUsage: 100 })],
|
||||
});
|
||||
|
||||
const pro = products.pro({
|
||||
id: "pro",
|
||||
items: [items.monthlyMessages({ includedUsage: 500 })],
|
||||
});
|
||||
|
||||
const { autumnV1 } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [free, pro] }),
|
||||
],
|
||||
actions: [s.billing.attach({ productId: free.id })],
|
||||
});
|
||||
|
||||
const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env });
|
||||
const coupon = await createPercentCoupon({ stripeCli, percentOff: 25 });
|
||||
const promoCode = await createPromotionCode({
|
||||
stripeCli,
|
||||
coupon,
|
||||
code: `SAVE25-${customerId}`,
|
||||
});
|
||||
|
||||
// Use the human-readable code string (not the promo code ID)
|
||||
await autumnV1.billing.attach({
|
||||
customer_id: customerId,
|
||||
product_id: pro.id,
|
||||
discounts: [{ promotion_code: promoCode.code }],
|
||||
});
|
||||
|
||||
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
|
||||
await expectCustomerProducts({
|
||||
customer,
|
||||
active: [pro.id],
|
||||
notPresent: [free.id],
|
||||
});
|
||||
|
||||
await expectCustomerInvoiceCorrect({
|
||||
customer,
|
||||
count: 1,
|
||||
latestTotal: 15,
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 4: Multiple rewards stack on new subscription
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Scenario:
|
||||
* - Customer on free product
|
||||
* - Create 20% off + $2 off coupons
|
||||
* - Attach pro ($20/mo) with both discounts
|
||||
*
|
||||
* Expected:
|
||||
* - Percent applied first: $20 * 0.8 = $16
|
||||
* - Then amount: $16 - $2 = $14
|
||||
* - Invoice = $14
|
||||
*/
|
||||
test.concurrent(`${chalk.yellowBright("attach-discount 4: multiple rewards stack on new subscription")}`, async () => {
|
||||
const customerId = "att-disc-multi";
|
||||
|
||||
const free = products.base({
|
||||
id: "free",
|
||||
items: [items.monthlyMessages({ includedUsage: 100 })],
|
||||
});
|
||||
|
||||
const pro = products.pro({
|
||||
id: "pro",
|
||||
items: [items.monthlyMessages({ includedUsage: 500 })],
|
||||
});
|
||||
|
||||
const { autumnV1 } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [free, pro] }),
|
||||
],
|
||||
actions: [s.billing.attach({ productId: free.id })],
|
||||
});
|
||||
|
||||
const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env });
|
||||
const pctCoupon = await createPercentCoupon({ stripeCli, percentOff: 20 });
|
||||
const amtCoupon = await createAmountCoupon({
|
||||
stripeCli,
|
||||
amountOffCents: 200,
|
||||
});
|
||||
|
||||
await autumnV1.billing.attach({
|
||||
customer_id: customerId,
|
||||
product_id: pro.id,
|
||||
discounts: [{ reward_id: pctCoupon.id }, { reward_id: amtCoupon.id }],
|
||||
});
|
||||
|
||||
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
|
||||
await expectCustomerProducts({
|
||||
customer,
|
||||
active: [pro.id],
|
||||
notPresent: [free.id],
|
||||
});
|
||||
|
||||
// 20% off $20 = $16, then $2 off = $14
|
||||
await expectCustomerInvoiceCorrect({
|
||||
customer,
|
||||
count: 1,
|
||||
latestTotal: 14,
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 5: Duplicate reward deduped
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Scenario:
|
||||
* - Customer on free product
|
||||
* - Create 20% off coupon
|
||||
* - Attach pro with same coupon passed twice in discounts array
|
||||
*
|
||||
* Expected:
|
||||
* - Only one discount applied (deduped by coupon ID)
|
||||
* - Invoice = $20 * 0.8 = $16 (not $20 * 0.8 * 0.8)
|
||||
*/
|
||||
test.concurrent(`${chalk.yellowBright("attach-discount 5: duplicate reward deduped")}`, async () => {
|
||||
const customerId = "att-disc-dedup";
|
||||
|
||||
const free = products.base({
|
||||
id: "free",
|
||||
items: [items.monthlyMessages({ includedUsage: 100 })],
|
||||
});
|
||||
|
||||
const pro = products.pro({
|
||||
id: "pro",
|
||||
items: [items.monthlyMessages({ includedUsage: 500 })],
|
||||
});
|
||||
|
||||
const { autumnV1 } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [free, pro] }),
|
||||
],
|
||||
actions: [s.billing.attach({ productId: free.id })],
|
||||
});
|
||||
|
||||
const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env });
|
||||
const coupon = await createPercentCoupon({ stripeCli, percentOff: 20 });
|
||||
|
||||
await autumnV1.billing.attach({
|
||||
customer_id: customerId,
|
||||
product_id: pro.id,
|
||||
discounts: [{ reward_id: coupon.id }, { reward_id: coupon.id }],
|
||||
});
|
||||
|
||||
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
|
||||
await expectCustomerProducts({
|
||||
customer,
|
||||
active: [pro.id],
|
||||
notPresent: [free.id],
|
||||
});
|
||||
|
||||
// Only one 20% discount, not double
|
||||
await expectCustomerInvoiceCorrect({
|
||||
customer,
|
||||
count: 1,
|
||||
latestTotal: 16,
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 6: Upgrade pro to premium with reward
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Scenario:
|
||||
* - Customer on pro ($20/mo)
|
||||
* - Create 20% off coupon
|
||||
* - Upgrade to premium ($50/mo) with discount
|
||||
*
|
||||
* Expected:
|
||||
* - At start of cycle: refund -$20 (full pro), charge $50 (full premium)
|
||||
* - Discount applies to charge: $50 * 0.8 = $40
|
||||
* - Total: -$20 + $40 = $20
|
||||
*/
|
||||
test.concurrent(`${chalk.yellowBright("attach-discount 6: upgrade pro to premium with reward")}`, async () => {
|
||||
const customerId = "att-disc-upgrade";
|
||||
|
||||
const pro = products.pro({
|
||||
id: "pro",
|
||||
items: [items.monthlyMessages({ includedUsage: 500 })],
|
||||
});
|
||||
|
||||
const premium = products.premium({
|
||||
id: "premium",
|
||||
items: [items.monthlyMessages({ includedUsage: 1000 })],
|
||||
});
|
||||
|
||||
const { autumnV1 } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [pro, premium] }),
|
||||
],
|
||||
actions: [s.billing.attach({ productId: pro.id })],
|
||||
});
|
||||
|
||||
const { stripeCli } = await getStripeSubscription({ customerId });
|
||||
const coupon = await createPercentCoupon({ stripeCli, percentOff: 20 });
|
||||
|
||||
// Preview should include discount
|
||||
const preview = await autumnV1.billing.previewAttach({
|
||||
customer_id: customerId,
|
||||
product_id: premium.id,
|
||||
discounts: [{ reward_id: coupon.id }],
|
||||
});
|
||||
|
||||
// Refund -$20 + discounted charge ($50 * 0.8 = $40) = $20
|
||||
expect(preview.total).toBe(20);
|
||||
|
||||
// Execute attach
|
||||
await autumnV1.billing.attach({
|
||||
customer_id: customerId,
|
||||
product_id: premium.id,
|
||||
discounts: [{ reward_id: coupon.id }],
|
||||
});
|
||||
|
||||
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
|
||||
await expectCustomerProducts({
|
||||
customer,
|
||||
active: [premium.id],
|
||||
notPresent: [pro.id],
|
||||
});
|
||||
|
||||
expectCustomerFeatureCorrect({
|
||||
customer,
|
||||
featureId: TestFeature.Messages,
|
||||
includedUsage: 1000,
|
||||
balance: 1000,
|
||||
usage: 0,
|
||||
});
|
||||
|
||||
// Invoices: pro ($20) + upgrade ($20)
|
||||
await expectCustomerInvoiceCorrect({
|
||||
customer,
|
||||
count: 2,
|
||||
latestTotal: 20,
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 7: Preview includes discount and matches execution
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Scenario:
|
||||
* - Customer on free product
|
||||
* - Create 25% off coupon
|
||||
* - Preview attach pro ($20/mo) with discount
|
||||
* - Execute attach with same discount
|
||||
*
|
||||
* Expected:
|
||||
* - Preview total = $20 * 0.75 = $15
|
||||
* - Invoice total matches preview
|
||||
*/
|
||||
test.concurrent(`${chalk.yellowBright("attach-discount 7: preview matches execution with discount")}`, async () => {
|
||||
const customerId = "att-disc-preview";
|
||||
|
||||
const free = products.base({
|
||||
id: "free",
|
||||
items: [items.monthlyMessages({ includedUsage: 100 })],
|
||||
});
|
||||
|
||||
const pro = products.pro({
|
||||
id: "pro",
|
||||
items: [items.monthlyMessages({ includedUsage: 500 })],
|
||||
});
|
||||
|
||||
const { autumnV1 } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [free, pro] }),
|
||||
],
|
||||
actions: [s.billing.attach({ productId: free.id })],
|
||||
});
|
||||
|
||||
const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env });
|
||||
const coupon = await createPercentCoupon({ stripeCli, percentOff: 25 });
|
||||
|
||||
// Preview
|
||||
const preview = await autumnV1.billing.previewAttach({
|
||||
customer_id: customerId,
|
||||
product_id: pro.id,
|
||||
discounts: [{ reward_id: coupon.id }],
|
||||
});
|
||||
expect(preview.total).toBe(15);
|
||||
|
||||
// Execute
|
||||
await autumnV1.billing.attach({
|
||||
customer_id: customerId,
|
||||
product_id: pro.id,
|
||||
discounts: [{ reward_id: coupon.id }],
|
||||
});
|
||||
|
||||
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
|
||||
// Invoice total matches preview
|
||||
await expectCustomerInvoiceCorrect({
|
||||
customer,
|
||||
count: 1,
|
||||
latestTotal: preview.total,
|
||||
});
|
||||
});
|
||||
@@ -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" }],
|
||||
});
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,369 @@
|
||||
/**
|
||||
* Integration tests for discount stacking when attaching with existing subscription discounts.
|
||||
*
|
||||
* Tests how param discounts interact with pre-existing Stripe subscription discounts:
|
||||
* - Param discounts merge with existing subscription discounts
|
||||
* - Duplicate coupons are deduplicated
|
||||
* - Percent + amount stacking order is preserved
|
||||
* - Multiple param discounts + existing discounts all stack correctly
|
||||
* - New subscription with discount (no existing discounts)
|
||||
*/
|
||||
|
||||
import { expect, test } from "bun:test";
|
||||
import type { ApiCustomerV3 } from "@autumn/shared";
|
||||
import { expectCustomerProducts } from "@tests/integration/billing/utils/expectCustomerProductCorrect.js";
|
||||
import { items } from "@tests/utils/fixtures/items.js";
|
||||
import { products } from "@tests/utils/fixtures/products.js";
|
||||
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
|
||||
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
|
||||
import chalk from "chalk";
|
||||
import { createStripeCli } from "@/external/connect/createStripeCli.js";
|
||||
import {
|
||||
applySubscriptionDiscount,
|
||||
createAmountCoupon,
|
||||
createPercentCoupon,
|
||||
getStripeSubscription,
|
||||
} from "../../utils/discounts/discountTestUtils.js";
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 1: Param discount stacks with existing sub discount
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Scenario:
|
||||
* - Customer on pro ($20/mo) with 10% coupon already on subscription
|
||||
* - Upgrade to premium ($50/mo) with 20% param discount
|
||||
*
|
||||
* Expected:
|
||||
* - Both discounts applied to charge
|
||||
* - Charge: $50, 10% off = $45, 20% off = $36
|
||||
* - Refund: -$20
|
||||
* - Total: -$20 + $36 = $16
|
||||
*/
|
||||
test.concurrent(`${chalk.yellowBright("attach-discount-stacking 1: param discount stacks with existing sub discount")}`, async () => {
|
||||
const customerId = "att-disc-stack-exist";
|
||||
|
||||
const pro = products.pro({
|
||||
id: "pro",
|
||||
items: [items.monthlyMessages({ includedUsage: 500 })],
|
||||
});
|
||||
|
||||
const premium = products.premium({
|
||||
id: "premium",
|
||||
items: [items.monthlyMessages({ includedUsage: 1000 })],
|
||||
});
|
||||
|
||||
const { autumnV1 } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [pro, premium] }),
|
||||
],
|
||||
actions: [s.billing.attach({ productId: pro.id })],
|
||||
});
|
||||
|
||||
const { stripeCli, subscription } = await getStripeSubscription({
|
||||
customerId,
|
||||
});
|
||||
|
||||
// Apply 10% discount to existing subscription
|
||||
const existingCoupon = await createPercentCoupon({
|
||||
stripeCli,
|
||||
percentOff: 10,
|
||||
});
|
||||
await applySubscriptionDiscount({
|
||||
stripeCli,
|
||||
subscriptionId: subscription.id,
|
||||
couponIds: [existingCoupon.id],
|
||||
});
|
||||
|
||||
// Create param discount: 20% off
|
||||
const paramCoupon = await createPercentCoupon({
|
||||
stripeCli,
|
||||
percentOff: 20,
|
||||
});
|
||||
|
||||
// Preview upgrade with param discount
|
||||
const preview = await autumnV1.billing.previewAttach({
|
||||
customer_id: customerId,
|
||||
product_id: premium.id,
|
||||
discounts: [{ reward_id: paramCoupon.id }],
|
||||
});
|
||||
|
||||
// Refund -$20 + charge $50 * 0.9 * 0.8 = $36 => total $16
|
||||
expect(preview.total).toBe(16);
|
||||
|
||||
// Execute
|
||||
await autumnV1.billing.attach({
|
||||
customer_id: customerId,
|
||||
product_id: premium.id,
|
||||
discounts: [{ reward_id: paramCoupon.id }],
|
||||
});
|
||||
|
||||
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
|
||||
await expectCustomerProducts({
|
||||
customer,
|
||||
active: [premium.id],
|
||||
notPresent: [pro.id],
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 2: Duplicate coupon deduped with existing sub discount
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Scenario:
|
||||
* - Customer on pro ($20/mo) with 20% coupon on subscription
|
||||
* - Upgrade to premium ($50/mo) with same coupon as param discount
|
||||
*
|
||||
* Expected:
|
||||
* - Deduped: only one instance of the coupon
|
||||
* - Charge: $50 * 0.8 = $40
|
||||
* - Refund: -$20
|
||||
* - Total: -$20 + $40 = $20
|
||||
*/
|
||||
test.concurrent(`${chalk.yellowBright("attach-discount-stacking 2: duplicate coupon deduped with existing")}`, async () => {
|
||||
const customerId = "att-disc-stack-dedup";
|
||||
|
||||
const pro = products.pro({
|
||||
id: "pro",
|
||||
items: [items.monthlyMessages({ includedUsage: 500 })],
|
||||
});
|
||||
|
||||
const premium = products.premium({
|
||||
id: "premium",
|
||||
items: [items.monthlyMessages({ includedUsage: 1000 })],
|
||||
});
|
||||
|
||||
const { autumnV1 } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [pro, premium] }),
|
||||
],
|
||||
actions: [s.billing.attach({ productId: pro.id })],
|
||||
});
|
||||
|
||||
const { stripeCli, subscription } = await getStripeSubscription({
|
||||
customerId,
|
||||
});
|
||||
|
||||
// Apply 20% coupon to existing subscription
|
||||
const coupon = await createPercentCoupon({ stripeCli, percentOff: 20 });
|
||||
await applySubscriptionDiscount({
|
||||
stripeCli,
|
||||
subscriptionId: subscription.id,
|
||||
couponIds: [coupon.id],
|
||||
});
|
||||
|
||||
// Pass the same coupon as param discount
|
||||
const preview = await autumnV1.billing.previewAttach({
|
||||
customer_id: customerId,
|
||||
product_id: premium.id,
|
||||
discounts: [{ reward_id: coupon.id }],
|
||||
});
|
||||
|
||||
// Only one 20% discount (deduped): $50 * 0.8 = $40, refund -$20, total $20
|
||||
expect(preview.total).toBe(20);
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 3: Param amount + existing percent stack correctly
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Scenario:
|
||||
* - Customer on pro ($20/mo) with 30% coupon on subscription
|
||||
* - Upgrade to premium ($50/mo) with $5 off param discount
|
||||
*
|
||||
* Expected:
|
||||
* - Percent applied first: $50 * 0.7 = $35
|
||||
* - Then amount: $35 - $5 = $30
|
||||
* - Refund: -$20
|
||||
* - Total: -$20 + $30 = $10
|
||||
*/
|
||||
test.concurrent(`${chalk.yellowBright("attach-discount-stacking 3: param amount + existing percent")}`, async () => {
|
||||
const customerId = "att-disc-stack-mixed";
|
||||
|
||||
const pro = products.pro({
|
||||
id: "pro",
|
||||
items: [items.monthlyMessages({ includedUsage: 500 })],
|
||||
});
|
||||
|
||||
const premium = products.premium({
|
||||
id: "premium",
|
||||
items: [items.monthlyMessages({ includedUsage: 1000 })],
|
||||
});
|
||||
|
||||
const { autumnV1 } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [pro, premium] }),
|
||||
],
|
||||
actions: [s.billing.attach({ productId: pro.id })],
|
||||
});
|
||||
|
||||
const { stripeCli, subscription } = await getStripeSubscription({
|
||||
customerId,
|
||||
});
|
||||
|
||||
// Existing: 30% off
|
||||
const existingCoupon = await createPercentCoupon({
|
||||
stripeCli,
|
||||
percentOff: 30,
|
||||
});
|
||||
await applySubscriptionDiscount({
|
||||
stripeCli,
|
||||
subscriptionId: subscription.id,
|
||||
couponIds: [existingCoupon.id],
|
||||
});
|
||||
|
||||
// Param: $5 off
|
||||
const paramCoupon = await createAmountCoupon({
|
||||
stripeCli,
|
||||
amountOffCents: 500,
|
||||
});
|
||||
|
||||
const preview = await autumnV1.billing.previewAttach({
|
||||
customer_id: customerId,
|
||||
product_id: premium.id,
|
||||
discounts: [{ reward_id: paramCoupon.id }],
|
||||
});
|
||||
|
||||
// Charge $50 * 0.7 = $35, then $5 off = $30, refund -$20, total $10
|
||||
expect(preview.total).toBe(10);
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 4: Multiple param discounts + existing discount
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Scenario:
|
||||
* - Customer on pro ($20/mo) with 10% coupon on subscription
|
||||
* - Upgrade to premium ($50/mo) with two param discounts: 20% + $3 off
|
||||
*
|
||||
* Expected:
|
||||
* - Three discounts total: 10%, 20%, $3 off
|
||||
* - Charge: $50 * 0.9 * 0.8 = $36, then $3 off = $33
|
||||
* - Refund: -$20
|
||||
* - Total: -$20 + $33 = $13
|
||||
*/
|
||||
test.concurrent(`${chalk.yellowBright("attach-discount-stacking 4: multiple param discounts + existing")}`, async () => {
|
||||
const customerId = "att-disc-stack-multi";
|
||||
|
||||
const pro = products.pro({
|
||||
id: "pro",
|
||||
items: [items.monthlyMessages({ includedUsage: 500 })],
|
||||
});
|
||||
|
||||
const premium = products.premium({
|
||||
id: "premium",
|
||||
items: [items.monthlyMessages({ includedUsage: 1000 })],
|
||||
});
|
||||
|
||||
const { autumnV1 } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [pro, premium] }),
|
||||
],
|
||||
actions: [s.billing.attach({ productId: pro.id })],
|
||||
});
|
||||
|
||||
const { stripeCli, subscription } = await getStripeSubscription({
|
||||
customerId,
|
||||
});
|
||||
|
||||
// Existing: 10% off
|
||||
const existingCoupon = await createPercentCoupon({
|
||||
stripeCli,
|
||||
percentOff: 10,
|
||||
});
|
||||
await applySubscriptionDiscount({
|
||||
stripeCli,
|
||||
subscriptionId: subscription.id,
|
||||
couponIds: [existingCoupon.id],
|
||||
});
|
||||
|
||||
// Param: 20% off + $3 off
|
||||
const pctCoupon = await createPercentCoupon({ stripeCli, percentOff: 20 });
|
||||
const amtCoupon = await createAmountCoupon({
|
||||
stripeCli,
|
||||
amountOffCents: 300,
|
||||
});
|
||||
|
||||
const preview = await autumnV1.billing.previewAttach({
|
||||
customer_id: customerId,
|
||||
product_id: premium.id,
|
||||
discounts: [{ reward_id: pctCoupon.id }, { reward_id: amtCoupon.id }],
|
||||
});
|
||||
|
||||
// Charge $50 * 0.9 * 0.8 = $36, $36 - $3 = $33, refund -$20, total $13
|
||||
expect(preview.total).toBe(13);
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 5: Discount on fresh subscription (no existing discounts)
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Scenario:
|
||||
* - Customer on free product (no Stripe subscription)
|
||||
* - Attach pro ($20/mo) with 50% param discount
|
||||
*
|
||||
* Expected:
|
||||
* - New subscription created with discount
|
||||
* - Invoice = $20 * 0.5 = $10
|
||||
*/
|
||||
test.concurrent(`${chalk.yellowBright("attach-discount-stacking 5: discount on fresh subscription")}`, async () => {
|
||||
const customerId = "att-disc-stack-fresh";
|
||||
|
||||
const free = products.base({
|
||||
id: "free",
|
||||
items: [items.monthlyMessages({ includedUsage: 100 })],
|
||||
});
|
||||
|
||||
const pro = products.pro({
|
||||
id: "pro",
|
||||
items: [items.monthlyMessages({ includedUsage: 500 })],
|
||||
});
|
||||
|
||||
const { autumnV1 } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [free, pro] }),
|
||||
],
|
||||
actions: [s.billing.attach({ productId: free.id })],
|
||||
});
|
||||
|
||||
const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env });
|
||||
const coupon = await createPercentCoupon({ stripeCli, percentOff: 50 });
|
||||
|
||||
// Preview
|
||||
const preview = await autumnV1.billing.previewAttach({
|
||||
customer_id: customerId,
|
||||
product_id: pro.id,
|
||||
discounts: [{ reward_id: coupon.id }],
|
||||
});
|
||||
expect(preview.total).toBe(10);
|
||||
|
||||
// Execute
|
||||
await autumnV1.billing.attach({
|
||||
customer_id: customerId,
|
||||
product_id: pro.id,
|
||||
discounts: [{ reward_id: coupon.id }],
|
||||
});
|
||||
|
||||
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
|
||||
await expectCustomerProducts({
|
||||
customer,
|
||||
active: [pro.id],
|
||||
notPresent: [free.id],
|
||||
});
|
||||
});
|
||||
@@ -787,8 +787,8 @@ test.concurrent(`${chalk.yellowBright("trial-entity-upgrade 5: both entities upg
|
||||
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
await expectCustomerInvoiceCorrect({
|
||||
customer,
|
||||
latestTotal: 0,
|
||||
count: 4,
|
||||
latestTotal: 0,
|
||||
});
|
||||
|
||||
// Verify Stripe subscription state
|
||||
|
||||
@@ -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()}`,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
// });
|
||||
});
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -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({
|
||||
|
||||
8
shared/api/billing/attachV2/attachDiscount.ts
Normal file
8
shared/api/billing/attachV2/attachDiscount.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const AttachDiscountSchema = z.union([
|
||||
z.object({ reward_id: z.string() }),
|
||||
z.object({ promotion_code: z.string() }),
|
||||
]);
|
||||
|
||||
export type AttachDiscount = z.infer<typeof AttachDiscountSchema>;
|
||||
@@ -3,6 +3,7 @@ import { PlanTimingSchema } from "../../../models/billingModels/context/attachBi
|
||||
import { ProductItemSchema } from "../../../models/productV2Models/productItemModels/productItemModels.js";
|
||||
import { BillingBehaviorSchema } from "../common/billingBehavior.js";
|
||||
import { BillingParamsBaseSchema } from "../common/billingParamsBase.js";
|
||||
import { AttachDiscountSchema } from "./attachDiscount.js";
|
||||
|
||||
export const RedirectModeSchema = z.enum(["always", "if_required", "never"]);
|
||||
export type RedirectMode = z.infer<typeof RedirectModeSchema>;
|
||||
@@ -24,6 +25,8 @@ export const ExtAttachParamsV0Schema = BillingParamsBaseSchema.extend({
|
||||
|
||||
plan_schedule: PlanTimingSchema.optional(),
|
||||
|
||||
// Discounts to apply (Stripe coupon IDs or human-readable promo code strings)
|
||||
discounts: z.array(AttachDiscountSchema).optional(),
|
||||
// Billing behavior for attach operations (product transitions):
|
||||
// - 'prorate_immediately' (default): Invoice line items are charged immediately
|
||||
// - 'next_cycle_only': Do NOT create any charges due to the attach
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
52
shared/utils/rewardUtils/rewardFilterUtils.ts
Normal file
52
shared/utils/rewardUtils/rewardFilterUtils.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { Reward, RewardProgram } from "../../index.js";
|
||||
|
||||
/**
|
||||
* Checks if a reward is applicable to a specific product.
|
||||
* A reward applies if:
|
||||
* - It has `apply_to_all: true` in its discount_config, OR
|
||||
* - There's a reward program linking it to this product
|
||||
*/
|
||||
export const isRewardApplicableToProduct = ({
|
||||
reward,
|
||||
rewardPrograms,
|
||||
productId,
|
||||
}: {
|
||||
reward: Reward;
|
||||
rewardPrograms: RewardProgram[];
|
||||
productId: string;
|
||||
}): boolean => {
|
||||
// Rewards with apply_to_all are applicable to all products
|
||||
if (reward.discount_config?.apply_to_all) return true;
|
||||
|
||||
// Find reward programs that link this reward to products
|
||||
const linkedPrograms = rewardPrograms.filter(
|
||||
(program) => program.internal_reward_id === reward.internal_id,
|
||||
);
|
||||
|
||||
// Check if any linked program includes this product
|
||||
// Note: product_ids defaults to [""] in DB, so filter out empty strings
|
||||
return linkedPrograms.some((program) => {
|
||||
const productIds = (program.product_ids || []).filter((id) => id !== "");
|
||||
return productIds.includes(productId);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Filters rewards to only those applicable to a specific product.
|
||||
* If no productId is provided, returns all rewards.
|
||||
*/
|
||||
export const filterRewardsByProduct = ({
|
||||
rewards,
|
||||
rewardPrograms,
|
||||
productId,
|
||||
}: {
|
||||
rewards: Reward[];
|
||||
rewardPrograms: RewardProgram[];
|
||||
productId: string | undefined;
|
||||
}): Reward[] => {
|
||||
if (!productId) return rewards;
|
||||
|
||||
return rewards.filter((reward) =>
|
||||
isRewardApplicableToProduct({ reward, rewardPrograms, productId }),
|
||||
);
|
||||
};
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
type ProductItem,
|
||||
} from "@autumn/shared";
|
||||
import { z } from "zod/v4";
|
||||
import type { FormDiscount } from "./utils/discountUtils";
|
||||
|
||||
export const AttachFormSchema = z.object({
|
||||
productId: z.string(),
|
||||
@@ -14,6 +15,7 @@ export const AttachFormSchema = z.object({
|
||||
trialDuration: z.enum(FreeTrialDuration),
|
||||
trialEnabled: z.boolean(),
|
||||
planSchedule: z.custom<PlanTiming>().nullable(),
|
||||
discounts: z.custom<FormDiscount[]>(),
|
||||
});
|
||||
|
||||
export type AttachForm = z.infer<typeof AttachFormSchema>;
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
import type { PlanTiming } from "@autumn/shared";
|
||||
import {
|
||||
CalendarIcon,
|
||||
CaretDownIcon,
|
||||
LightningIcon,
|
||||
PlusIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import type { Transition, Variants } from "motion/react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
STAGGER_CONTAINER,
|
||||
STAGGER_ITEM,
|
||||
} from "@/components/forms/update-subscription-v2/constants/animationConstants";
|
||||
import { IconButton } from "@/components/v2/buttons/IconButton";
|
||||
import { IconCheckbox } from "@/components/v2/checkboxes/IconCheckbox";
|
||||
import {
|
||||
LAYOUT_TRANSITION,
|
||||
SheetSection,
|
||||
} from "@/components/v2/sheets/SharedSheetComponents";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/v2/tooltips/Tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAttachFormContext } from "../context/AttachFormProvider";
|
||||
import { addDiscount } from "../utils/discountUtils";
|
||||
import { AttachDiscountRow } from "./AttachDiscountRow";
|
||||
|
||||
const ACCORDION_EASE = [0.32, 0.72, 0, 1] as const;
|
||||
|
||||
const ACCORDION_EXPAND: Transition = {
|
||||
duration: 0.35,
|
||||
ease: ACCORDION_EASE,
|
||||
};
|
||||
|
||||
const ACCORDION_COLLAPSE: Transition = {
|
||||
duration: 0.25,
|
||||
ease: ACCORDION_EASE,
|
||||
delay: 0.1,
|
||||
};
|
||||
|
||||
const ACCORDION_CONTENT: Variants = {
|
||||
hidden: {
|
||||
transition: { staggerChildren: 0.04, staggerDirection: -1 },
|
||||
},
|
||||
visible: {
|
||||
transition: { delayChildren: 0.15, staggerChildren: 0.06 },
|
||||
},
|
||||
};
|
||||
|
||||
const ACCORDION_ITEM: Variants = {
|
||||
hidden: {
|
||||
opacity: 0,
|
||||
y: -4,
|
||||
transition: { duration: 0.12, ease: ACCORDION_EASE },
|
||||
},
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { duration: 0.25, ease: ACCORDION_EASE },
|
||||
},
|
||||
};
|
||||
|
||||
export function AttachAdvancedSection() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { form, formValues, previewQuery } = useAttachFormContext();
|
||||
const { planSchedule, discounts } = formValues;
|
||||
const previewData = previewQuery.data;
|
||||
|
||||
const defaultPlanSchedule = useMemo((): PlanTiming => {
|
||||
if (!previewData) return "immediate";
|
||||
|
||||
const hasOutgoing = previewData.outgoing.length > 0;
|
||||
if (!hasOutgoing) return "immediate";
|
||||
|
||||
const incomingPrice = previewData.incoming[0]?.plan.price?.amount ?? 0;
|
||||
const outgoingPrice = previewData.outgoing[0]?.plan.price?.amount ?? 0;
|
||||
const isUpgrade = incomingPrice > outgoingPrice;
|
||||
|
||||
return isUpgrade ? "immediate" : "end_of_cycle";
|
||||
}, [previewData]);
|
||||
|
||||
const effectivePlanSchedule = planSchedule ?? defaultPlanSchedule;
|
||||
const hasCustomSchedule =
|
||||
planSchedule !== null && planSchedule !== defaultPlanSchedule;
|
||||
const hasDiscounts = discounts.some((d) => {
|
||||
if ("reward_id" in d) return d.reward_id !== "";
|
||||
if ("promotion_code" in d) return d.promotion_code !== "";
|
||||
return false;
|
||||
});
|
||||
const hasCustomSettings = hasCustomSchedule || hasDiscounts;
|
||||
|
||||
const handleScheduleChange = (value: PlanTiming) => {
|
||||
form.setFieldValue("planSchedule", value);
|
||||
};
|
||||
|
||||
const handleAddDiscount = () => {
|
||||
form.setFieldValue("discounts", addDiscount(discounts));
|
||||
};
|
||||
|
||||
const isImmediateSelected = effectivePlanSchedule === "immediate";
|
||||
const isEndOfCycleSelected = effectivePlanSchedule === "end_of_cycle";
|
||||
|
||||
const getCustomSettingsTooltip = (): string => {
|
||||
const parts: string[] = [];
|
||||
|
||||
if (hasCustomSchedule) {
|
||||
parts.push(
|
||||
`Plan schedule: ${isImmediateSelected ? "Immediate" : "End of cycle"}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (hasDiscounts) {
|
||||
const validCount = discounts.filter((d) => {
|
||||
if ("reward_id" in d) return d.reward_id !== "";
|
||||
if ("promotion_code" in d) return d.promotion_code !== "";
|
||||
return false;
|
||||
}).length;
|
||||
parts.push(`${validCount} discount${validCount > 1 ? "s" : ""}`);
|
||||
}
|
||||
|
||||
return parts.join(" • ");
|
||||
};
|
||||
|
||||
return (
|
||||
<SheetSection withSeparator>
|
||||
<motion.div
|
||||
layout="position"
|
||||
layoutDependency={formValues.productId}
|
||||
transition={{ layout: LAYOUT_TRANSITION }}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={STAGGER_CONTAINER}
|
||||
>
|
||||
<motion.div variants={STAGGER_ITEM}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen((prev) => !prev)}
|
||||
className="flex items-center justify-between w-full cursor-pointer select-none"
|
||||
>
|
||||
<h3 className="text-sub flex items-center gap-2">
|
||||
Advanced
|
||||
<AnimatePresence>
|
||||
{hasCustomSettings && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<motion.span
|
||||
initial={{ opacity: 0, scale: 0 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="size-1.5 rounded-full bg-blue-400"
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{getCustomSettingsTooltip()}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</h3>
|
||||
<motion.span
|
||||
animate={{ rotate: isOpen ? 180 : 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="text-t3"
|
||||
>
|
||||
<CaretDownIcon size={12} />
|
||||
</motion.span>
|
||||
</button>
|
||||
</motion.div>
|
||||
|
||||
<AnimatePresence initial={false}>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ height: 0 }}
|
||||
animate={{
|
||||
height: "auto",
|
||||
transition: {
|
||||
height: ACCORDION_EXPAND,
|
||||
},
|
||||
}}
|
||||
exit={{
|
||||
height: 0,
|
||||
transition: {
|
||||
height: ACCORDION_COLLAPSE,
|
||||
},
|
||||
}}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<motion.div
|
||||
className="pt-2 space-y-2"
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="hidden"
|
||||
variants={ACCORDION_CONTENT}
|
||||
>
|
||||
{/* Plan Schedule */}
|
||||
<motion.div variants={ACCORDION_ITEM}>
|
||||
<div className="flex items-center justify-between px-3 h-10 rounded-xl input-base">
|
||||
<span className="text-sm text-t2">Plan Schedule</span>
|
||||
<div className="flex">
|
||||
<IconCheckbox
|
||||
icon={<LightningIcon />}
|
||||
iconOrientation="left"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
checked={isImmediateSelected}
|
||||
onCheckedChange={() =>
|
||||
handleScheduleChange("immediate")
|
||||
}
|
||||
className={cn(
|
||||
"rounded-r-none",
|
||||
!isImmediateSelected && "border-r-0",
|
||||
)}
|
||||
>
|
||||
Immediately
|
||||
</IconCheckbox>
|
||||
<IconCheckbox
|
||||
icon={<CalendarIcon />}
|
||||
iconOrientation="left"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
checked={isEndOfCycleSelected}
|
||||
onCheckedChange={() =>
|
||||
handleScheduleChange("end_of_cycle")
|
||||
}
|
||||
className={cn(
|
||||
"rounded-l-none",
|
||||
!isEndOfCycleSelected && "border-l-0",
|
||||
)}
|
||||
>
|
||||
End of cycle
|
||||
</IconCheckbox>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Discounts */}
|
||||
<motion.div variants={ACCORDION_ITEM}>
|
||||
<div className="rounded-xl input-base px-3 py-2">
|
||||
<div className="flex items-center justify-between h-6">
|
||||
<span className="text-sm text-t2">Discounts</span>
|
||||
<IconButton
|
||||
variant="muted"
|
||||
size="sm"
|
||||
onClick={handleAddDiscount}
|
||||
icon={<PlusIcon size={12} />}
|
||||
className="text-t3"
|
||||
>
|
||||
Add
|
||||
</IconButton>
|
||||
</div>
|
||||
{discounts.length > 0 && (
|
||||
<div className="mt-2 pt-2 border-t border-border space-y-2">
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{discounts.map((discount, index) => (
|
||||
<motion.div
|
||||
key={discount._id}
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<AttachDiscountRow index={index} />
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</SheetSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import type { Reward } from "@autumn/shared";
|
||||
import { filterRewardsByProduct, RewardType } from "@autumn/shared";
|
||||
import { XIcon } from "@phosphor-icons/react";
|
||||
import { CheckIcon } from "lucide-react";
|
||||
import { IconButton } from "@/components/v2/buttons/IconButton";
|
||||
import { SearchableSelect } from "@/components/v2/selects/SearchableSelect";
|
||||
import { useRewardsQuery } from "@/hooks/queries/useRewardsQuery";
|
||||
import { useAttachFormContext } from "../context/AttachFormProvider";
|
||||
import { removeDiscount, updateDiscount } from "../utils/discountUtils";
|
||||
|
||||
interface AttachDiscountRowProps {
|
||||
index: number;
|
||||
}
|
||||
|
||||
/** Filters rewards to only show discount types (not free products) */
|
||||
const filterDiscountRewards = (rewards: Reward[]): Reward[] => {
|
||||
return rewards.filter(
|
||||
(r) =>
|
||||
r.type === RewardType.PercentageDiscount ||
|
||||
r.type === RewardType.FixedDiscount,
|
||||
);
|
||||
};
|
||||
|
||||
export function AttachDiscountRow({ index }: AttachDiscountRowProps) {
|
||||
const { form, formValues, product } = useAttachFormContext();
|
||||
const { rewards, rewardPrograms } = useRewardsQuery();
|
||||
|
||||
const discounts = formValues.discounts;
|
||||
const discount = discounts[index];
|
||||
|
||||
if (!discount) return null;
|
||||
|
||||
const discountRewards = filterDiscountRewards(rewards);
|
||||
const productFilteredRewards = filterRewardsByProduct({
|
||||
rewards: discountRewards,
|
||||
rewardPrograms,
|
||||
productId: product?.id,
|
||||
});
|
||||
|
||||
// Get reward IDs already selected in other rows
|
||||
const selectedRewardIds = discounts
|
||||
.filter((d, i) => i !== index && "reward_id" in d)
|
||||
.map((d) => ("reward_id" in d ? d.reward_id : ""))
|
||||
.filter(Boolean);
|
||||
|
||||
// Filter out already-selected rewards
|
||||
const availableRewards = productFilteredRewards.filter(
|
||||
(r) => !selectedRewardIds.includes(r.id),
|
||||
);
|
||||
|
||||
const handleRewardChange = (rewardId: string) => {
|
||||
form.setFieldValue(
|
||||
"discounts",
|
||||
updateDiscount(discounts, index, { reward_id: rewardId }),
|
||||
);
|
||||
};
|
||||
|
||||
const handleRemove = () => {
|
||||
form.setFieldValue("discounts", removeDiscount(discounts, index));
|
||||
};
|
||||
|
||||
const currentRewardId = "reward_id" in discount ? discount.reward_id : "";
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 h-8">
|
||||
{/* Reward select */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<SearchableSelect
|
||||
value={currentRewardId}
|
||||
onValueChange={handleRewardChange}
|
||||
options={availableRewards}
|
||||
getOptionValue={(r) => r.id}
|
||||
getOptionLabel={(r) => r.name || r.id}
|
||||
placeholder="Select reward..."
|
||||
searchable
|
||||
searchPlaceholder="Search rewards..."
|
||||
emptyText="No rewards found"
|
||||
triggerClassName="h-7 px-2 text-xs border-0 shadow-none bg-transparent hover:bg-muted/50"
|
||||
renderOption={(reward, isSelected) => (
|
||||
<>
|
||||
<span className="flex-1 truncate min-w-0">
|
||||
{reward.name || reward.id}
|
||||
</span>
|
||||
{reward.promo_codes?.[0]?.code && (
|
||||
<span className="text-t3 text-xs shrink-0">
|
||||
{reward.promo_codes[0].code}
|
||||
</span>
|
||||
)}
|
||||
{isSelected && <CheckIcon className="size-4 shrink-0" />}
|
||||
</>
|
||||
)}
|
||||
renderValue={(reward) => {
|
||||
if (!reward)
|
||||
return <span className="text-t3">Select reward...</span>;
|
||||
return (
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="truncate">{reward.name || reward.id}</span>
|
||||
{reward.promo_codes?.[0]?.code && (
|
||||
<span className="text-t3 text-xs shrink-0">
|
||||
{reward.promo_codes[0].code}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Remove button */}
|
||||
<IconButton
|
||||
variant="muted"
|
||||
size="sm"
|
||||
onClick={handleRemove}
|
||||
icon={<XIcon size={12} />}
|
||||
className="shrink-0 text-t3 hover:text-red-500"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,8 +4,12 @@ import { PlanItemsSection } from "@/components/forms/shared";
|
||||
import {
|
||||
STAGGER_CONTAINER,
|
||||
STAGGER_ITEM,
|
||||
STAGGER_ITEM_LAYOUT,
|
||||
} from "@/components/forms/update-subscription-v2/constants/animationConstants";
|
||||
import { SheetSection } from "@/components/v2/sheets/SharedSheetComponents";
|
||||
import {
|
||||
LAYOUT_TRANSITION,
|
||||
SheetSection,
|
||||
} from "@/components/v2/sheets/SharedSheetComponents";
|
||||
import { useOrg } from "@/hooks/common/useOrg";
|
||||
import { useAttachFormContext } from "../context/AttachFormProvider";
|
||||
import { outgoingToProductItems } from "../utils/attachDiffUtils";
|
||||
@@ -85,7 +89,11 @@ export function AttachPlanSection() {
|
||||
animate="visible"
|
||||
variants={STAGGER_CONTAINER}
|
||||
>
|
||||
<motion.div variants={STAGGER_ITEM}>
|
||||
<motion.div
|
||||
layout="position"
|
||||
transition={{ layout: LAYOUT_TRANSITION }}
|
||||
variants={STAGGER_ITEM_LAYOUT}
|
||||
>
|
||||
<h3 className="text-sub select-none w-full">
|
||||
<AttachSectionTitle />
|
||||
</h3>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { GearIcon, TimerIcon } from "@phosphor-icons/react";
|
||||
import { TimerIcon } from "@phosphor-icons/react";
|
||||
import { motion } from "motion/react";
|
||||
import {
|
||||
STAGGER_CONTAINER,
|
||||
@@ -24,24 +24,14 @@ export function AttachPlanSkeleton() {
|
||||
<span className="flex items-center gap-1.5">
|
||||
Plan Configuration
|
||||
</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<IconButton
|
||||
icon={<GearIcon size={14} />}
|
||||
variant="secondary"
|
||||
className="h-7 whitespace-nowrap"
|
||||
disabled
|
||||
>
|
||||
Settings
|
||||
</IconButton>
|
||||
<IconButton
|
||||
icon={<TimerIcon size={14} />}
|
||||
variant="secondary"
|
||||
className="h-7 whitespace-nowrap"
|
||||
disabled
|
||||
>
|
||||
Free Trial
|
||||
</IconButton>
|
||||
</span>
|
||||
<IconButton
|
||||
icon={<TimerIcon size={14} />}
|
||||
variant="secondary"
|
||||
className="h-7 whitespace-nowrap"
|
||||
disabled
|
||||
>
|
||||
Free Trial
|
||||
</IconButton>
|
||||
</span>
|
||||
</h3>
|
||||
</motion.div>
|
||||
|
||||
@@ -2,9 +2,11 @@ import type { AxiosError } from "axios";
|
||||
import { format } from "date-fns";
|
||||
import { motion } from "motion/react";
|
||||
import { PreviewErrorDisplay } from "@/components/forms/update-subscription-v2/components/PreviewErrorDisplay";
|
||||
import { LAYOUT_TRANSITION } from "@/components/forms/update-subscription-v2/constants/animationConstants";
|
||||
import { LineItemsPreview } from "@/components/v2/LineItemsPreview";
|
||||
import { SheetSection } from "@/components/v2/sheets/SharedSheetComponents";
|
||||
import {
|
||||
LAYOUT_TRANSITION,
|
||||
SheetSection,
|
||||
} from "@/components/v2/sheets/SharedSheetComponents";
|
||||
import { getBackendErr } from "@/utils/genUtils";
|
||||
import { useAttachFormContext } from "../context/AttachFormProvider";
|
||||
|
||||
@@ -43,7 +45,7 @@ export function AttachPreviewSection() {
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<motion.div layout transition={LAYOUT_TRANSITION}>
|
||||
<motion.div layout="position" transition={{ layout: LAYOUT_TRANSITION }}>
|
||||
<SheetSection title="Pricing Preview" withSeparator>
|
||||
<PreviewErrorDisplay error={error} />
|
||||
</SheetSection>
|
||||
@@ -52,7 +54,7 @@ export function AttachPreviewSection() {
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div layout transition={LAYOUT_TRANSITION}>
|
||||
<motion.div layout="position" transition={{ layout: LAYOUT_TRANSITION }}>
|
||||
<LineItemsPreview
|
||||
title="Pricing Preview"
|
||||
isLoading={isLoading}
|
||||
|
||||
@@ -21,6 +21,7 @@ export function AttachProductSelection() {
|
||||
<field.SelectField
|
||||
label=""
|
||||
searchable
|
||||
defaultOpen
|
||||
options={availableProducts.map((p) => ({
|
||||
label: p.name,
|
||||
value: p.id,
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
} from "@/components/v2/tooltips/Tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAttachFormContext } from "../context/AttachFormProvider";
|
||||
import { AttachSettingsPopover } from "./AttachSettingsPopover";
|
||||
|
||||
export function AttachSectionTitle() {
|
||||
const { hasCustomizations, form, formValues } = useAttachFormContext();
|
||||
@@ -35,36 +34,33 @@ export function AttachSectionTitle() {
|
||||
</Tooltip>
|
||||
)}
|
||||
</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<AttachSettingsPopover />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<IconButton
|
||||
icon={
|
||||
<TimerIcon
|
||||
size={14}
|
||||
weight={trialIsActive ? "fill" : "regular"}
|
||||
/>
|
||||
}
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
"h-7 whitespace-nowrap",
|
||||
trialIsActive &&
|
||||
"text-purple-400! border-purple-500/50 bg-purple-500/10",
|
||||
trialEnabled && !trialIsActive && "border-primary",
|
||||
)}
|
||||
onClick={() => form.setFieldValue("trialEnabled", !trialEnabled)}
|
||||
>
|
||||
Free Trial
|
||||
</IconButton>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
{trialIsActive
|
||||
? "Trial configured - click to edit"
|
||||
: "Add a free trial"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<IconButton
|
||||
icon={
|
||||
<TimerIcon
|
||||
size={14}
|
||||
weight={trialIsActive ? "fill" : "regular"}
|
||||
/>
|
||||
}
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
"h-7 whitespace-nowrap",
|
||||
trialIsActive &&
|
||||
"text-purple-400! border-purple-500/50 bg-purple-500/10",
|
||||
trialEnabled && !trialIsActive && "border-primary",
|
||||
)}
|
||||
onClick={() => form.setFieldValue("trialEnabled", !trialEnabled)}
|
||||
>
|
||||
Free Trial
|
||||
</IconButton>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
{trialIsActive
|
||||
? "Trial configured - click to edit"
|
||||
: "Add a free trial"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,15 +3,19 @@ import { motion } from "motion/react";
|
||||
import {
|
||||
STAGGER_CONTAINER,
|
||||
STAGGER_ITEM,
|
||||
STAGGER_ITEM_LAYOUT,
|
||||
} from "@/components/forms/update-subscription-v2/constants/animationConstants";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { SheetSection } from "@/components/v2/sheets/SharedSheetComponents";
|
||||
import {
|
||||
LAYOUT_TRANSITION,
|
||||
SheetSection,
|
||||
} from "@/components/v2/sheets/SharedSheetComponents";
|
||||
import { InfoBox } from "@/views/onboarding2/integrate/components/InfoBox";
|
||||
import { useAttachFormContext } from "../context/AttachFormProvider";
|
||||
|
||||
function AttachUpdatesSkeleton() {
|
||||
return (
|
||||
<SheetSection withSeparator>
|
||||
<SheetSection withSeparator={false}>
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
@@ -70,13 +74,19 @@ export function AttachUpdatesSection() {
|
||||
};
|
||||
|
||||
return (
|
||||
<SheetSection withSeparator>
|
||||
<SheetSection withSeparator={false} className="pb-0">
|
||||
<motion.div
|
||||
layout="position"
|
||||
transition={{ layout: LAYOUT_TRANSITION }}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={STAGGER_CONTAINER}
|
||||
>
|
||||
<motion.div variants={STAGGER_ITEM}>
|
||||
<motion.div
|
||||
layout="position"
|
||||
transition={{ layout: LAYOUT_TRANSITION }}
|
||||
variants={STAGGER_ITEM_LAYOUT}
|
||||
>
|
||||
<InfoBox variant="note">
|
||||
Attaching{" "}
|
||||
<PlusCircleIcon
|
||||
|
||||
@@ -96,6 +96,7 @@ export function AttachFormProvider({
|
||||
trialDuration,
|
||||
trialEnabled,
|
||||
planSchedule,
|
||||
discounts,
|
||||
} = formValues;
|
||||
|
||||
const product = useMemo(
|
||||
@@ -169,6 +170,7 @@ export function AttachFormProvider({
|
||||
trialDuration,
|
||||
trialEnabled,
|
||||
planSchedule,
|
||||
discounts,
|
||||
});
|
||||
|
||||
const previewQuery = useAttachPreview({ requestBody });
|
||||
|
||||
@@ -19,6 +19,7 @@ export function useAttachForm({
|
||||
trialDuration: FreeTrialDuration.Day,
|
||||
trialEnabled: false,
|
||||
planSchedule: null,
|
||||
discounts: [],
|
||||
} as AttachForm,
|
||||
validators: {
|
||||
onChange: AttachFormSchema,
|
||||
|
||||
@@ -12,6 +12,10 @@ import {
|
||||
import Decimal from "decimal.js";
|
||||
import { useMemo } from "react";
|
||||
import { getFreeTrial } from "@/components/forms/update-subscription-v2/utils/getFreeTrial";
|
||||
import {
|
||||
type FormDiscount,
|
||||
filterValidDiscounts,
|
||||
} from "../utils/discountUtils";
|
||||
|
||||
interface UseAttachRequestBodyParams {
|
||||
customerId: string | undefined;
|
||||
@@ -24,6 +28,7 @@ interface UseAttachRequestBodyParams {
|
||||
trialDuration: FreeTrialDuration;
|
||||
trialEnabled: boolean;
|
||||
planSchedule: PlanTiming | null;
|
||||
discounts: FormDiscount[];
|
||||
}
|
||||
|
||||
function convertPrepaidOptionsToFeatureOptions({
|
||||
@@ -75,6 +80,7 @@ export function useAttachRequestBody({
|
||||
trialDuration,
|
||||
trialEnabled,
|
||||
planSchedule,
|
||||
discounts,
|
||||
}: UseAttachRequestBodyParams) {
|
||||
const requestBody = useMemo((): AttachParamsV0 | null => {
|
||||
if (!customerId || !product) {
|
||||
@@ -125,6 +131,11 @@ export function useAttachRequestBody({
|
||||
body.plan_schedule = planSchedule;
|
||||
}
|
||||
|
||||
const validDiscounts = filterValidDiscounts(discounts);
|
||||
if (validDiscounts.length > 0) {
|
||||
body.discounts = validDiscounts;
|
||||
}
|
||||
|
||||
return body;
|
||||
}, [
|
||||
customerId,
|
||||
@@ -137,6 +148,7 @@ export function useAttachRequestBody({
|
||||
trialDuration,
|
||||
trialEnabled,
|
||||
planSchedule,
|
||||
discounts,
|
||||
]);
|
||||
|
||||
const buildRequestBody = useMemo(
|
||||
|
||||
@@ -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";
|
||||
|
||||
71
vite/src/components/forms/attach-v2/utils/discountUtils.ts
Normal file
71
vite/src/components/forms/attach-v2/utils/discountUtils.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import type { AttachDiscount } from "@autumn/shared";
|
||||
|
||||
export type DiscountMode = "reward" | "promo";
|
||||
|
||||
/** Form discount with unique ID for stable React keys */
|
||||
export type FormDiscount = AttachDiscount & { _id: string };
|
||||
|
||||
let discountIdCounter = 0;
|
||||
const generateDiscountId = (): string => {
|
||||
discountIdCounter += 1;
|
||||
return `discount-${discountIdCounter}-${Date.now()}`;
|
||||
};
|
||||
|
||||
export const getDiscountMode = (discount: FormDiscount): DiscountMode => {
|
||||
return "reward_id" in discount ? "reward" : "promo";
|
||||
};
|
||||
|
||||
export const createDiscount = (mode: DiscountMode): FormDiscount => {
|
||||
const base = mode === "reward" ? { reward_id: "" } : { promotion_code: "" };
|
||||
return { ...base, _id: generateDiscountId() };
|
||||
};
|
||||
|
||||
export const addDiscount = (discounts: FormDiscount[]): FormDiscount[] => {
|
||||
return [...discounts, createDiscount("reward")];
|
||||
};
|
||||
|
||||
export const removeDiscount = (
|
||||
discounts: FormDiscount[],
|
||||
index: number,
|
||||
): FormDiscount[] => {
|
||||
return discounts.filter((_, i) => i !== index);
|
||||
};
|
||||
|
||||
export const updateDiscount = (
|
||||
discounts: FormDiscount[],
|
||||
index: number,
|
||||
updates: AttachDiscount,
|
||||
): FormDiscount[] => {
|
||||
const newDiscounts = [...discounts];
|
||||
const existing = newDiscounts[index];
|
||||
newDiscounts[index] = { ...updates, _id: existing._id } as FormDiscount;
|
||||
return newDiscounts;
|
||||
};
|
||||
|
||||
export const toggleDiscountMode = (
|
||||
discounts: FormDiscount[],
|
||||
index: number,
|
||||
newMode: DiscountMode,
|
||||
): FormDiscount[] => {
|
||||
const base =
|
||||
newMode === "reward" ? { reward_id: "" } : { promotion_code: "" };
|
||||
return updateDiscount(discounts, index, base);
|
||||
};
|
||||
|
||||
/** Converts form discounts to API format (strips _id) */
|
||||
export const toApiDiscounts = (discounts: FormDiscount[]): AttachDiscount[] => {
|
||||
return discounts.map(({ _id, ...rest }) => rest);
|
||||
};
|
||||
|
||||
/** Filters out empty/invalid discounts before sending to API */
|
||||
export const filterValidDiscounts = (
|
||||
discounts: FormDiscount[],
|
||||
): AttachDiscount[] => {
|
||||
return toApiDiscounts(
|
||||
discounts.filter((d) => {
|
||||
if ("reward_id" in d) return d.reward_id !== "";
|
||||
if ("promotion_code" in d) return d.promotion_code !== "";
|
||||
return false;
|
||||
}),
|
||||
);
|
||||
};
|
||||
@@ -18,13 +18,14 @@ import { TrialEditorRow } from "@/components/forms/update-subscription-v2/compon
|
||||
import { VersionChangeRow } from "@/components/forms/update-subscription-v2/components/VersionChangeRow";
|
||||
import {
|
||||
FAST_TRANSITION,
|
||||
LAYOUT_TRANSITION,
|
||||
STAGGER_CONTAINER,
|
||||
STAGGER_ITEM,
|
||||
STAGGER_ITEM_LAYOUT,
|
||||
} from "@/components/forms/update-subscription-v2/constants/animationConstants";
|
||||
import type { UseTrialStateReturn } from "@/components/forms/update-subscription-v2/hooks/useTrialState";
|
||||
import type { UseUpdateSubscriptionForm } from "@/components/forms/update-subscription-v2/hooks/useUpdateSubscriptionForm";
|
||||
import { Button } from "@/components/v2/buttons/Button";
|
||||
import { LAYOUT_TRANSITION } from "@/components/v2/sheets/SharedSheetComponents";
|
||||
|
||||
interface PriceChange {
|
||||
oldPrice: string;
|
||||
@@ -197,9 +198,9 @@ export function PlanItemsSection({
|
||||
return (
|
||||
<motion.div
|
||||
key={featureId || item.price_id || index}
|
||||
layout
|
||||
variants={useStaggerAnimation ? STAGGER_ITEM : undefined}
|
||||
transition={LAYOUT_TRANSITION}
|
||||
layout="position"
|
||||
variants={useStaggerAnimation ? STAGGER_ITEM_LAYOUT : undefined}
|
||||
transition={{ layout: LAYOUT_TRANSITION }}
|
||||
>
|
||||
<SubscriptionItemRow
|
||||
item={item}
|
||||
@@ -216,9 +217,9 @@ export function PlanItemsSection({
|
||||
const renderDeletedItemRow = (item: ProductItem, index: number) => (
|
||||
<motion.div
|
||||
key={`deleted-${item.feature_id || index}`}
|
||||
layout
|
||||
variants={useStaggerAnimation ? STAGGER_ITEM : undefined}
|
||||
transition={LAYOUT_TRANSITION}
|
||||
layout="position"
|
||||
variants={useStaggerAnimation ? STAGGER_ITEM_LAYOUT : undefined}
|
||||
transition={{ layout: LAYOUT_TRANSITION }}
|
||||
>
|
||||
<SubscriptionItemRow item={item} isDeleted />
|
||||
</motion.div>
|
||||
@@ -229,9 +230,9 @@ export function PlanItemsSection({
|
||||
return (
|
||||
<motion.div
|
||||
key="version-change"
|
||||
layout
|
||||
variants={useStaggerAnimation ? STAGGER_ITEM : undefined}
|
||||
transition={LAYOUT_TRANSITION}
|
||||
layout="position"
|
||||
variants={useStaggerAnimation ? STAGGER_ITEM_LAYOUT : undefined}
|
||||
transition={{ layout: LAYOUT_TRANSITION }}
|
||||
>
|
||||
<VersionChangeRow
|
||||
currentVersion={versionChange.currentVersion}
|
||||
@@ -250,7 +251,7 @@ export function PlanItemsSection({
|
||||
<motion.div
|
||||
key="trial-editor"
|
||||
layout
|
||||
transition={LAYOUT_TRANSITION}
|
||||
transition={{ layout: LAYOUT_TRANSITION }}
|
||||
variants={useStaggerAnimation ? STAGGER_ITEM : undefined}
|
||||
>
|
||||
<TrialEditorRow
|
||||
@@ -272,19 +273,15 @@ export function PlanItemsSection({
|
||||
{trialConfig.trialEnabled && (
|
||||
<motion.div
|
||||
key="trial-editor"
|
||||
layout
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { ...FAST_TRANSITION, delay: 0.15 },
|
||||
transition: FAST_TRANSITION,
|
||||
}}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
y: -8,
|
||||
transition: FAST_TRANSITION,
|
||||
}}
|
||||
transition={LAYOUT_TRANSITION}
|
||||
>
|
||||
<TrialEditorRow
|
||||
form={form}
|
||||
@@ -298,9 +295,9 @@ export function PlanItemsSection({
|
||||
|
||||
const renderEditButton = () => (
|
||||
<motion.div
|
||||
layout
|
||||
variants={useStaggerAnimation ? STAGGER_ITEM : undefined}
|
||||
transition={LAYOUT_TRANSITION}
|
||||
layout="position"
|
||||
transition={{ layout: LAYOUT_TRANSITION }}
|
||||
variants={useStaggerAnimation ? STAGGER_ITEM_LAYOUT : undefined}
|
||||
>
|
||||
<Button variant="secondary" onClick={onEditPlan} className="w-full">
|
||||
<PencilSimpleIcon size={14} className="mr-1" />
|
||||
@@ -314,12 +311,16 @@ export function PlanItemsSection({
|
||||
<LayoutGroup>
|
||||
<motion.div
|
||||
className="space-y-2"
|
||||
layout="position"
|
||||
transition={{ layout: LAYOUT_TRANSITION }}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={STAGGER_CONTAINER}
|
||||
>
|
||||
<motion.div
|
||||
variants={STAGGER_ITEM}
|
||||
layout="position"
|
||||
transition={{ layout: LAYOUT_TRANSITION }}
|
||||
variants={STAGGER_ITEM_LAYOUT}
|
||||
className="flex gap-2 justify-between items-center"
|
||||
>
|
||||
{renderPriceDisplay()}
|
||||
@@ -339,13 +340,17 @@ export function PlanItemsSection({
|
||||
{renderPriceDisplay()}
|
||||
</div>
|
||||
<LayoutGroup>
|
||||
<div className="space-y-2">
|
||||
<motion.div
|
||||
className="space-y-2"
|
||||
layout="position"
|
||||
transition={{ layout: LAYOUT_TRANSITION }}
|
||||
>
|
||||
{product?.items?.map(renderItemRow)}
|
||||
{deletedItems.map(renderDeletedItemRow)}
|
||||
{renderVersionChangeRow()}
|
||||
{renderTrialEditor()}
|
||||
{renderEditButton()}
|
||||
</div>
|
||||
</motion.div>
|
||||
</LayoutGroup>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -16,12 +16,13 @@ export function EditPlanSection() {
|
||||
originalItems,
|
||||
initialPrepaidOptions,
|
||||
productWithFormItems: product,
|
||||
isVersionReady,
|
||||
handleEditPlan,
|
||||
} = useUpdateSubscriptionFormContext();
|
||||
|
||||
const { customerProduct, numVersions, currentVersion } = formContext;
|
||||
const { prepaidOptions } = formValues;
|
||||
const hasCustomizations = formValues.items !== null;
|
||||
const hasCustomizations = formValues.items !== null || isVersionReady;
|
||||
|
||||
const { org } = useOrg();
|
||||
const currency = org?.default_currency ?? "USD";
|
||||
@@ -89,7 +90,9 @@ export function EditPlanSection() {
|
||||
|
||||
const selectedVersion = form.getFieldValue("version");
|
||||
const versionChange =
|
||||
currentVersion !== undefined && selectedVersion !== undefined
|
||||
isVersionReady &&
|
||||
currentVersion !== undefined &&
|
||||
selectedVersion !== undefined
|
||||
? { currentVersion, selectedVersion }
|
||||
: null;
|
||||
|
||||
@@ -97,7 +100,7 @@ export function EditPlanSection() {
|
||||
<SheetSection
|
||||
title={
|
||||
<SectionTitle
|
||||
hasCustomizations={hasCustomizations}
|
||||
hasCustomizations={formValues.items !== null}
|
||||
form={form}
|
||||
numVersions={numVersions}
|
||||
currentVersion={currentVersion}
|
||||
|
||||
@@ -179,18 +179,13 @@ export function TrialEditorRow({
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<motion.div
|
||||
layout
|
||||
transition={FAST_TRANSITION}
|
||||
className="flex items-center h-10 px-3 rounded-xl input-base gap-2 overflow-hidden"
|
||||
>
|
||||
<div className="flex items-center h-10 px-3 rounded-xl input-base gap-2 overflow-hidden">
|
||||
<AnimatePresence mode="popLayout" initial={false}>
|
||||
<motion.div
|
||||
key="display"
|
||||
layout
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: 10 }}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={FAST_TRANSITION}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
@@ -206,7 +201,7 @@ export function TrialEditorRow({
|
||||
/>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -249,18 +244,13 @@ export function TrialEditorRow({
|
||||
<span className="text-sm text-t2">Free Trial</span>
|
||||
</div>
|
||||
</div>
|
||||
<motion.div
|
||||
layout
|
||||
transition={FAST_TRANSITION}
|
||||
className="flex items-center h-10 px-3 rounded-xl input-base gap-2 overflow-hidden"
|
||||
>
|
||||
<div className="flex items-center h-10 px-3 rounded-xl input-base gap-2 overflow-hidden">
|
||||
<AnimatePresence mode="popLayout" initial={false}>
|
||||
<motion.div
|
||||
key="edit"
|
||||
layout
|
||||
initial={{ opacity: 0, x: 10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -10 }}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={FAST_TRANSITION}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
@@ -315,7 +305,7 @@ export function TrialEditorRow({
|
||||
/>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,6 +31,15 @@ export const STAGGER_ITEM: Variants = {
|
||||
},
|
||||
};
|
||||
|
||||
/** Opacity-only stagger item for elements using layout="position" — avoids y transform conflicts with layout animations */
|
||||
export const STAGGER_ITEM_LAYOUT: Variants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: { duration: 0.25, ease: [0.32, 0.72, 0, 1] },
|
||||
},
|
||||
};
|
||||
|
||||
export const STAGGER_ITEM_DELAYED: Variants = {
|
||||
hidden: { opacity: 0, y: 8 },
|
||||
visible: {
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
useUpdateSubscriptionPreview,
|
||||
} from "@/components/forms/update-subscription/use-update-subscription-preview";
|
||||
import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery";
|
||||
import { useProductVersionQuery } from "@/hooks/queries/useProductVersionQuery";
|
||||
import type { PrepaidItemWithFeature } from "@/hooks/stores/useProductStore";
|
||||
import { useHasBillingChanges } from "@/hooks/stores/useProductStore";
|
||||
import { useHasSubscriptionChanges } from "../hooks/useHasSubscriptionChanges";
|
||||
@@ -62,6 +63,7 @@ interface UpdateSubscriptionFormContextValue {
|
||||
initialPrepaidOptions: Record<string, number>;
|
||||
changedPrepaidOptions: Record<string, number> | undefined;
|
||||
productWithFormItems: FrontendProduct | undefined;
|
||||
isVersionReady: boolean;
|
||||
hasChanges: boolean;
|
||||
hasNoBillingChanges: boolean;
|
||||
|
||||
@@ -121,6 +123,23 @@ export function UpdateSubscriptionFormProvider({
|
||||
const formValues = useStore(form.store, (state) => state.values);
|
||||
const { prepaidOptions } = formValues;
|
||||
|
||||
// Fetch the target version's product data when version differs from current
|
||||
const isVersionChanged = formValues.version !== currentVersion;
|
||||
const versionProductQuery = useProductVersionQuery({
|
||||
productId: product?.id,
|
||||
version: formValues.version,
|
||||
enabled: isVersionChanged,
|
||||
});
|
||||
|
||||
// Use the target version's product when a different version is selected
|
||||
const isVersionReady = isVersionChanged && !!versionProductQuery.data;
|
||||
const effectiveProduct = useMemo((): ProductV2 | undefined => {
|
||||
if (isVersionReady) {
|
||||
return versionProductQuery.data?.product;
|
||||
}
|
||||
return product;
|
||||
}, [product, isVersionReady, versionProductQuery.data]);
|
||||
|
||||
const defaultValues = form.options.defaultValues;
|
||||
const initialPrepaidOptions = defaultValues?.prepaidOptions ?? {};
|
||||
|
||||
@@ -145,10 +164,10 @@ export function UpdateSubscriptionFormProvider({
|
||||
}, [prepaidOptions, initialPrepaidOptions]);
|
||||
|
||||
const productWithFormItems = useMemo((): FrontendProduct | undefined => {
|
||||
if (!product) return undefined;
|
||||
if (!effectiveProduct) return undefined;
|
||||
|
||||
const baseFrontendProduct = productV2ToFrontendProduct({
|
||||
product: product as ProductV2,
|
||||
product: effectiveProduct as ProductV2,
|
||||
});
|
||||
|
||||
if (formValues.items) {
|
||||
@@ -159,7 +178,7 @@ export function UpdateSubscriptionFormProvider({
|
||||
}
|
||||
|
||||
return baseFrontendProduct;
|
||||
}, [product, formValues.items]);
|
||||
}, [effectiveProduct, formValues.items]);
|
||||
|
||||
const baseProduct = useMemo((): FrontendProduct | undefined => {
|
||||
if (!product) return undefined;
|
||||
@@ -167,9 +186,11 @@ export function UpdateSubscriptionFormProvider({
|
||||
}, [product]);
|
||||
|
||||
const newProduct = useMemo((): FrontendProduct | undefined => {
|
||||
if (!product) return undefined;
|
||||
if (!effectiveProduct) return undefined;
|
||||
|
||||
const base = productV2ToFrontendProduct({ product: product as ProductV2 });
|
||||
const base = productV2ToFrontendProduct({
|
||||
product: effectiveProduct as ProductV2,
|
||||
});
|
||||
|
||||
const freeTrial = getFreeTrial({
|
||||
removeTrial: formValues.removeTrial,
|
||||
@@ -186,7 +207,7 @@ export function UpdateSubscriptionFormProvider({
|
||||
free_trial: freeTrialValue,
|
||||
};
|
||||
}, [
|
||||
product,
|
||||
effectiveProduct,
|
||||
formValues.items,
|
||||
formValues.removeTrial,
|
||||
formValues.trialLength,
|
||||
@@ -200,8 +221,12 @@ export function UpdateSubscriptionFormProvider({
|
||||
});
|
||||
|
||||
const hasPrepaidQuantityChanges = changedPrepaidOptions !== undefined;
|
||||
const isVersionLoading = isVersionChanged && !isVersionReady;
|
||||
const hasNoBillingChanges =
|
||||
hasChanges && !hasBillingChanges && !hasPrepaidQuantityChanges;
|
||||
hasChanges &&
|
||||
!hasBillingChanges &&
|
||||
!hasPrepaidQuantityChanges &&
|
||||
!isVersionLoading;
|
||||
|
||||
const freeTrial = getFreeTrial({
|
||||
removeTrial: formValues.removeTrial,
|
||||
@@ -286,6 +311,7 @@ export function UpdateSubscriptionFormProvider({
|
||||
initialPrepaidOptions,
|
||||
changedPrepaidOptions,
|
||||
productWithFormItems,
|
||||
isVersionReady,
|
||||
hasChanges,
|
||||
hasNoBillingChanges,
|
||||
previewQuery,
|
||||
@@ -307,6 +333,7 @@ export function UpdateSubscriptionFormProvider({
|
||||
initialPrepaidOptions,
|
||||
changedPrepaidOptions,
|
||||
productWithFormItems,
|
||||
isVersionReady,
|
||||
hasChanges,
|
||||
hasNoBillingChanges,
|
||||
previewQuery,
|
||||
|
||||
@@ -22,6 +22,7 @@ export function SelectField<T extends string | number = string>({
|
||||
searchable = false,
|
||||
searchPlaceholder = "Search...",
|
||||
emptyText = "No results found",
|
||||
defaultOpen = false,
|
||||
}: {
|
||||
label: string;
|
||||
options: SelectFieldOption<T>[];
|
||||
@@ -34,6 +35,7 @@ export function SelectField<T extends string | number = string>({
|
||||
searchable?: boolean;
|
||||
searchPlaceholder?: string;
|
||||
emptyText?: string;
|
||||
defaultOpen?: boolean;
|
||||
}) {
|
||||
const field = useFieldContext<T>();
|
||||
const stringValue = String(field.state.value);
|
||||
@@ -60,6 +62,7 @@ export function SelectField<T extends string | number = string>({
|
||||
searchPlaceholder={searchPlaceholder}
|
||||
emptyText={emptyText}
|
||||
disabled={disabled}
|
||||
defaultOpen={defaultOpen}
|
||||
renderValue={(opt) => (
|
||||
<>
|
||||
<span className={!opt ? "text-t3" : undefined}>
|
||||
|
||||
@@ -5,15 +5,11 @@ import * as React from "react";
|
||||
import SmallSpinner from "@/components/general/SmallSpinner";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// hover:border-primary
|
||||
// focus-visible:bg-active-primary focus-visible:border-primary
|
||||
// active:bg-active-primary active:border-primary
|
||||
|
||||
// Remove ring styles
|
||||
// focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive
|
||||
const buttonVariants = cva(
|
||||
`inline-flex items-center justify-center gap-2 whitespace-nowrap text-sm disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none cursor-pointer
|
||||
rounded-lg group/btn transition-none w-fit transition-all duration-100
|
||||
rounded-lg group/btn transition-colors duration-100 w-fit transform-gpu
|
||||
`,
|
||||
{
|
||||
variants: {
|
||||
@@ -100,35 +96,6 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
const buttonRef = React.useRef<HTMLButtonElement>(null);
|
||||
const [contentWidth, setContentWidth] = React.useState<number | null>(null);
|
||||
|
||||
// Combine refs using useImperativeHandle
|
||||
React.useImperativeHandle(
|
||||
ref,
|
||||
() => buttonRef.current as HTMLButtonElement,
|
||||
[],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (buttonRef.current && !isLoading) {
|
||||
// Measure the full button width including padding
|
||||
const width = buttonRef.current.offsetWidth;
|
||||
setContentWidth(width);
|
||||
}
|
||||
}, [isLoading]);
|
||||
|
||||
// Measure width on mount and when loading state changes
|
||||
React.useEffect(() => {
|
||||
if (buttonRef.current && !isLoading) {
|
||||
// Use requestAnimationFrame to ensure DOM is fully rendered
|
||||
requestAnimationFrame(() => {
|
||||
if (buttonRef.current) {
|
||||
const width = buttonRef.current.offsetWidth;
|
||||
setContentWidth(width);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [isLoading]);
|
||||
|
||||
const getDisableActiveStyles = () => {
|
||||
if (!disableActive) return "";
|
||||
@@ -166,14 +133,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
className={cn(
|
||||
buttonVariants({ variant, size, className }),
|
||||
getDisableActiveStyles(),
|
||||
// transition && "transition-all duration-150",
|
||||
)}
|
||||
style={{
|
||||
minWidth:
|
||||
isLoading && contentWidth
|
||||
? `${contentWidth + 0.2}px` // Add small buffer to prevent any slight width changes
|
||||
: undefined,
|
||||
}}
|
||||
disabled={isLoading || props.disabled}
|
||||
{...props}
|
||||
>
|
||||
@@ -183,7 +143,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
className={variant === "primary" ? "relative z-10" : undefined}
|
||||
/>
|
||||
) : variant === "primary" ? (
|
||||
<span className="relative z-10 inline-flex items-center gap-2">
|
||||
<span className="relative z-10 inline-flex items-center gap-2 transition-none">
|
||||
{children}
|
||||
</span>
|
||||
) : (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { CheckIcon, ChevronDownIcon } from "lucide-react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
@@ -32,6 +33,7 @@ export type SearchableSelectProps<T> = {
|
||||
disabled?: boolean;
|
||||
triggerClassName?: string;
|
||||
contentClassName?: string;
|
||||
defaultOpen?: boolean;
|
||||
};
|
||||
|
||||
export function SearchableSelect<T>({
|
||||
@@ -50,9 +52,16 @@ export function SearchableSelect<T>({
|
||||
disabled = false,
|
||||
triggerClassName,
|
||||
contentClassName,
|
||||
defaultOpen = false,
|
||||
}: SearchableSelectProps<T>) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!defaultOpen) return;
|
||||
const timer = setTimeout(() => setOpen(true), 200);
|
||||
return () => clearTimeout(timer);
|
||||
}, [defaultOpen]);
|
||||
|
||||
const selectedOption = options.find((opt) => getOptionValue(opt) === value);
|
||||
|
||||
const handleSelect = (option: T) => {
|
||||
@@ -104,64 +113,78 @@ export function SearchableSelect<T>({
|
||||
<ChevronDownIcon className="size-4 shrink-0 opacity-50" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className={cn(
|
||||
"w-(--radix-popover-trigger-width) p-0 z-200 rounded-md overflow-hidden",
|
||||
contentClassName,
|
||||
)}
|
||||
>
|
||||
<Command
|
||||
className="bg-interactive-secondary"
|
||||
filter={
|
||||
searchable
|
||||
? (optionValue, search) => {
|
||||
const option = options.find(
|
||||
(opt) => getOptionValue(opt) === optionValue,
|
||||
);
|
||||
if (!option) return 0;
|
||||
const searchLower = search.toLowerCase();
|
||||
const labelMatch = getOptionLabel(option)
|
||||
.toLowerCase()
|
||||
.includes(searchLower);
|
||||
const valueMatch = optionValue
|
||||
.toLowerCase()
|
||||
.includes(searchLower);
|
||||
return labelMatch || valueMatch ? 1 : 0;
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<PopoverContent
|
||||
forceMount
|
||||
align="start"
|
||||
className={cn(
|
||||
"w-(--radix-popover-trigger-width) p-0 z-200 rounded-md overflow-hidden",
|
||||
contentClassName,
|
||||
)}
|
||||
asChild
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<Command
|
||||
className="bg-interactive-secondary"
|
||||
filter={
|
||||
searchable
|
||||
? (optionValue, search) => {
|
||||
const option = options.find(
|
||||
(opt) => getOptionValue(opt) === optionValue,
|
||||
);
|
||||
if (!option) return 0;
|
||||
const searchLower = search.toLowerCase();
|
||||
const labelMatch = getOptionLabel(option)
|
||||
.toLowerCase()
|
||||
.includes(searchLower);
|
||||
const valueMatch = optionValue
|
||||
.toLowerCase()
|
||||
.includes(searchLower);
|
||||
return labelMatch || valueMatch ? 1 : 0;
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{searchable && <CommandInput placeholder={searchPlaceholder} />}
|
||||
<CommandList>
|
||||
<CommandEmpty className="text-t3">{emptyText}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{options.map((option) => {
|
||||
const optionValue = getOptionValue(option);
|
||||
const isSelected = optionValue === value;
|
||||
const isDisabled = getOptionDisabled?.(option) ?? false;
|
||||
>
|
||||
{searchable && <CommandInput placeholder={searchPlaceholder} />}
|
||||
<CommandList>
|
||||
<CommandEmpty className="text-t3">{emptyText}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{options.map((option) => {
|
||||
const optionValue = getOptionValue(option);
|
||||
const isSelected = optionValue === value;
|
||||
const isDisabled = getOptionDisabled?.(option) ?? false;
|
||||
|
||||
return (
|
||||
<CommandItem
|
||||
key={optionValue}
|
||||
value={optionValue}
|
||||
onSelect={() => handleSelect(option)}
|
||||
disabled={isDisabled}
|
||||
className={cn(
|
||||
"min-w-0",
|
||||
isDisabled && "text-t4 pointer-events-none opacity-50",
|
||||
)}
|
||||
>
|
||||
{renderOption
|
||||
? renderOption(option, isSelected)
|
||||
: defaultRenderOption(option, isSelected)}
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
return (
|
||||
<CommandItem
|
||||
key={optionValue}
|
||||
value={optionValue}
|
||||
onSelect={() => handleSelect(option)}
|
||||
disabled={isDisabled}
|
||||
className={cn(
|
||||
"min-w-0",
|
||||
isDisabled &&
|
||||
"text-t4 pointer-events-none opacity-50",
|
||||
)}
|
||||
>
|
||||
{renderOption
|
||||
? renderOption(option, isSelected)
|
||||
: defaultRenderOption(option, isSelected)}
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</motion.div>
|
||||
</PopoverContent>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { CaretRightIcon } from "@phosphor-icons/react";
|
||||
import { motion } from "motion/react";
|
||||
import { useId } from "react";
|
||||
import { LAYOUT_TRANSITION as ANIM_LAYOUT_TRANSITION } from "@/components/forms/update-subscription-v2/constants/animationConstants";
|
||||
import { Separator } from "@/components/v2/separator";
|
||||
import { type SheetType, useSheetStore } from "@/hooks/stores/useSheetStore";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -122,7 +121,11 @@ export function SheetSection({
|
||||
{children}
|
||||
</div>
|
||||
{withSeparator && (
|
||||
<motion.div layout transition={ANIM_LAYOUT_TRANSITION} className="px-4">
|
||||
<motion.div
|
||||
layout
|
||||
transition={{ layout: LAYOUT_TRANSITION }}
|
||||
className="px-4"
|
||||
>
|
||||
<Separator />
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
26
vite/src/hooks/queries/useProductVersionQuery.tsx
Normal file
26
vite/src/hooks/queries/useProductVersionQuery.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { ProductV2 } from "@autumn/shared";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAxiosInstance } from "@/services/useAxiosInstance";
|
||||
|
||||
/** Fetches product data for a specific version (or latest if version is omitted). */
|
||||
export function useProductVersionQuery({
|
||||
productId,
|
||||
version,
|
||||
enabled,
|
||||
}: {
|
||||
productId: string | undefined;
|
||||
version?: number;
|
||||
enabled?: boolean;
|
||||
}) {
|
||||
const axiosInstance = useAxiosInstance();
|
||||
return useQuery({
|
||||
queryKey: ["product-version", productId, version],
|
||||
queryFn: async () => {
|
||||
const { data } = await axiosInstance.get(`/products/${productId}/data`, {
|
||||
params: version ? { version } : undefined,
|
||||
});
|
||||
return data as { product: ProductV2; numVersions: number };
|
||||
},
|
||||
enabled: enabled !== false && !!productId,
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Entity, FullCustomer } from "@autumn/shared";
|
||||
import {
|
||||
AttachAdvancedSection,
|
||||
AttachFooter,
|
||||
AttachFormProvider,
|
||||
AttachPlanSection,
|
||||
@@ -75,8 +76,9 @@ function SheetContent() {
|
||||
|
||||
{hasProductSelected && (
|
||||
<>
|
||||
<AttachUpdatesSection />
|
||||
<AttachPlanSection />
|
||||
<AttachAdvancedSection />
|
||||
<AttachUpdatesSection />
|
||||
<AttachPreviewSection />
|
||||
<AttachFooter />
|
||||
</>
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -19,7 +19,7 @@ export function AttachProductSheetTrigger() {
|
||||
const feature = features.features.find((f) => f.id === entity?.feature_id);
|
||||
|
||||
const handleClick = () => {
|
||||
setSheet({ type: "attach-product" });
|
||||
setSheet({ type: "attach-product-v2" });
|
||||
};
|
||||
return (
|
||||
<Button
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { AppEnv } from "@autumn/shared";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
@@ -7,9 +8,11 @@ import { Link } from "react-router";
|
||||
import { useHasChanges } from "@/hooks/stores/useProductStore";
|
||||
import { useSheetStore } from "@/hooks/stores/useSheetStore";
|
||||
import { useEntity } from "@/hooks/stores/useSubscriptionStore";
|
||||
import { useEnv } from "@/utils/envUtils";
|
||||
import { pushPage } from "@/utils/genUtils";
|
||||
import ErrorScreen from "@/views/general/ErrorScreen";
|
||||
import LoadingScreen from "@/views/general/LoadingScreen";
|
||||
import { useOnboardingVisibility } from "@/views/onboarding4/hooks/useOnboardingProgress";
|
||||
import { OnboardingGuide } from "@/views/onboarding4/OnboardingGuide";
|
||||
import { useCusQuery } from "../../customers/customer/hooks/useCusQuery";
|
||||
import { useCusReferralQuery } from "../../customers/customer/hooks/useCusReferralQuery";
|
||||
@@ -37,6 +40,10 @@ export default function CustomerView2() {
|
||||
const hasCustomizedProduct = !!sheetData?.customizedProduct;
|
||||
const [isInlineEditorOpen, setIsInlineEditorOpen] = useState(false);
|
||||
|
||||
const env = useEnv();
|
||||
const { isDismissed } = useOnboardingVisibility();
|
||||
const showOnboarding = env === AppEnv.Sandbox && !isDismissed;
|
||||
|
||||
// useSheetCleanup();
|
||||
|
||||
if (cusLoading) return <LoadingScreen />;
|
||||
@@ -74,11 +81,16 @@ export default function CustomerView2() {
|
||||
transition={SHEET_ANIMATION}
|
||||
>
|
||||
<div className="flex flex-col overflow-x-hidden overflow-y-auto absolute inset-0 pb-8">
|
||||
<div className="w-full max-w-5xl mx-auto pt-8 pb-6 px-10">
|
||||
<OnboardingGuide />
|
||||
</div>
|
||||
{/* Rest of content shrinks normally with the container */}
|
||||
<div className="flex flex-col gap-4 w-full max-w-5xl mx-auto pt-4 px-10">
|
||||
{/* Onboarding Guide - only render wrapper when visible */}
|
||||
{showOnboarding && (
|
||||
<div className="w-full max-w-5xl mx-auto pt-8 px-10">
|
||||
<OnboardingGuide />
|
||||
</div>
|
||||
)}
|
||||
{/* Rest of content */}
|
||||
<div
|
||||
className={`flex flex-col gap-4 w-full max-w-5xl mx-auto px-10 ${showOnboarding ? "pt-4" : "pt-8"}`}
|
||||
>
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<div className="flex flex-col w-full">
|
||||
<div className="flex items-center justify-between w-full gap-4">
|
||||
|
||||
Reference in New Issue
Block a user