merged from main

This commit is contained in:
John Yeo
2026-02-17 13:49:20 +00:00
14 changed files with 1238 additions and 83 deletions

View File

@@ -86,11 +86,7 @@ export const handleVoidInvoiceCron = async ({
} catch (error) {
logger.error(`Error voiding invoice: ${error}`);
}
} else if (
invoice.status === "void" ||
invoice.status === "paid" ||
invoice.status === "uncollectible"
) {
} else if (invoice.status === "void" || invoice.status === "uncollectible") {
await MetadataService.delete({
db,
id: metadata.id,

View File

@@ -1,5 +1,5 @@
import {
type AppEnv,
AppEnv,
type FullProduct,
formatAmount,
getProductItemDisplay,
@@ -148,11 +148,17 @@ const listVercelPlansForOrg = async ({
metadata?: Record<string, any>;
canCancel?: boolean;
}) => {
const allowedIds =
(env === AppEnv.Live
? org.processor_configs?.vercel?.allowed_product_ids_live
: org.processor_configs?.vercel?.allowed_product_ids_sandbox) ?? [];
const products = await ProductService.listFull({
db,
orgId: org.id,
env,
archived: false,
inIds: allowedIds.length > 0 ? allowedIds : undefined,
});
sortProductsByPrice({ products });

View File

@@ -1,4 +1,9 @@
import { cusProductToProduct } from "@autumn/shared";
import {
cusProductToProduct,
mapToProductV2,
type FullCusProduct,
productV2ToBasePrice,
} from "@autumn/shared";
import { createRoute } from "@/honoMiddlewares/routeHandler.js";
import { CusService } from "@/internal/customers/CusService.js";
import type { VercelBillingPlan } from "../../misc/vercelTypes.js";
@@ -26,15 +31,29 @@ export const handleGetInstallation = createRoute({
);
}
const getPlanAmount = (cusProduct: FullCusProduct) => {
const product = cusProductToProduct({ cusProduct });
const productV2 = mapToProductV2({ product });
const basePrice = productV2ToBasePrice({ product: productV2 });
return basePrice?.price ?? 0;
};
const nonAddonProducts = (customer.customer_products || []).filter(
(customerProduct) => !customerProduct.product.is_add_on,
);
const customerProduct =
nonAddonProducts.sort((a, b) => getPlanAmount(b) - getPlanAmount(a))[0] ??
customer.customer_products?.[0];
return c.json(
{
notification: null,
billingPlan:
// edge case: [0] = add-on [1] = main
customer.customer_products?.[0] !== undefined
customerProduct !== undefined
? (productToBillingPlan({
product: cusProductToProduct({
cusProduct: customer.customer_products?.[0],
cusProduct: customerProduct,
}),
orgCurrency: org?.default_currency ?? "usd",
}) satisfies VercelBillingPlan)

View File

@@ -25,12 +25,12 @@ export const setupDefaultProductContext = async ({
if (nullish(params.cancel_action)) return undefined;
// Add-ons don't trigger default products
const { valid: isMainCustomerScopedAndPaid } = cp(customerProduct)
const { valid: isMainCustomerScopedAndRecurring } = cp(customerProduct)
.main()
.paidRecurring()
.recurring()
.customerScoped();
if (!isMainCustomerScopedAndPaid) return undefined;
if (!isMainCustomerScopedAndRecurring) return undefined;
const defaultProduct = await getFreeDefaultProductByGroup({
ctx,

View File

@@ -1,15 +1,16 @@
import { createStripeCli } from "@server/external/connect/createStripeCli";
import type { AutumnContext } from "@server/honoUtils/HonoEnv";
import type Stripe from "stripe";
import { logSubscriptionScheduleAction } from "@/internal/billing/v2/providers/stripe/utils/subscriptionSchedules/logSubscriptionScheduleAction";
import type {
BillingContext,
StripeSubscriptionScheduleAction,
} from "@autumn/shared";
import { createStripeCli } from "@server/external/connect/createStripeCli";
import type { AutumnContext } from "@server/honoUtils/HonoEnv";
import type Stripe from "stripe";
import { logSubscriptionScheduleAction } from "@/internal/billing/v2/providers/stripe/utils/subscriptionSchedules/logSubscriptionScheduleAction";
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService";
/**
* Maps update phase format to create phase format (strips start_date).
* Preserves discounts so they carry forward to the new schedule phases.
*/
const toCreatePhase = (
phase: Stripe.SubscriptionScheduleUpdateParams.Phase,
@@ -19,6 +20,9 @@ const toCreatePhase = (
quantity: item.quantity,
})),
end_date: typeof phase.end_date === "number" ? phase.end_date : undefined,
discounts: phase.discounts as
| Stripe.SubscriptionScheduleCreateParams.Phase.Discount[]
| undefined,
});
/**

View File

@@ -1,4 +1,4 @@
import type { BillingContext } from "@autumn/shared";
import type { BillingContext, StripeDiscountWithCoupon } from "@autumn/shared";
import {
type FullCusProduct,
msToSeconds,
@@ -74,6 +74,23 @@ const customerProductsToPhaseItems = ({
});
};
/**
* Converts billing context discounts to the format expected by Stripe schedule phases.
* Uses the existing discount ID so Stripe reuses the same discount object,
* preserving the original start/end timestamps and remaining duration for repeating coupons.
*/
const stripeDiscountsToPhaseDiscounts = ({
stripeDiscounts,
}: {
stripeDiscounts?: StripeDiscountWithCoupon[];
}): Stripe.SubscriptionScheduleUpdateParams.Phase.Discount[] | undefined => {
if (!stripeDiscounts || stripeDiscounts.length === 0) return undefined;
return stripeDiscounts.map((discount) => ({
discount: discount.source.coupon.id,
}));
};
/**
* Builds Stripe subscription schedule phases.
*
@@ -174,7 +191,9 @@ export const buildStripePhasesUpdate = ({
start_date: msToSeconds(startMs),
end_date: endMs ? msToSeconds(endMs) : undefined,
trial_end: computePhaseTrialEndsAt(),
discounts,
discounts: stripeDiscountsToPhaseDiscounts({
stripeDiscounts: billingContext.stripeDiscounts,
}),
};
// Log phase details

View File

@@ -36,6 +36,8 @@ export const getVercelConfigDisplay = ({
webhook_url: undefined,
custom_payment_method: undefined,
marketplace_mode: undefined,
allowed_product_ids_live: undefined,
allowed_product_ids_sandbox: undefined,
};
}
@@ -64,15 +66,31 @@ export const getVercelConfigDisplay = ({
webhook_url: mask(webhookUrl, 8, 6),
custom_payment_method: mask(customPaymentMethod, 5, 3),
marketplace_mode: vercelConfig.marketplace_mode,
allowed_product_ids_live: vercelConfig.allowed_product_ids_live,
allowed_product_ids_sandbox: vercelConfig.allowed_product_ids_sandbox,
};
};
export const handleUpsertVercelConfig = createRoute({
body: UpsertVercelProcessorConfigSchema,
handler: async (c) => {
const { db, org } = c.get("ctx");
const { db, org, env } = c.get("ctx");
const body = c.req.valid("json");
const normalizeAllowedProductIds = (ids: string[] | undefined) => {
return Array.from(
new Set(ids?.map((id) => id.trim()).filter((id) => !!id)),
);
};
const liveAllowedProductIds =
body.allowed_product_ids_live !== undefined
? normalizeAllowedProductIds(body.allowed_product_ids_live)
: undefined;
const sandboxAllowedProductIds =
body.allowed_product_ids_sandbox !== undefined
? normalizeAllowedProductIds(body.allowed_product_ids_sandbox)
: undefined;
// Merge with existing processor_configs to avoid unsetting fields
const existingVercelConfig =
@@ -87,6 +105,43 @@ export const handleUpsertVercelConfig = createRoute({
}
: undefined;
const envSpecificClientConfig: Partial<VercelProcessorConfig> = {};
if (env === AppEnv.Live) {
if (body.client_integration_id) {
envSpecificClientConfig.client_integration_id =
body.client_integration_id;
}
if (body.client_secret) {
envSpecificClientConfig.client_secret = body.client_secret;
}
if (body.webhook_url) {
envSpecificClientConfig.webhook_url = body.webhook_url;
}
} else {
if (body.client_integration_id || body.sandbox_client_id) {
envSpecificClientConfig.sandbox_client_id =
body.client_integration_id || body.sandbox_client_id;
}
if (body.client_secret || body.sandbox_client_secret) {
envSpecificClientConfig.sandbox_client_secret =
body.client_secret || body.sandbox_client_secret;
}
if (body.webhook_url || body.sandbox_webhook_url) {
envSpecificClientConfig.sandbox_webhook_url =
body.webhook_url || body.sandbox_webhook_url;
}
}
const allowedProductIdsUpdates: Partial<VercelProcessorConfig> = {};
if (env === AppEnv.Live && liveAllowedProductIds !== undefined) {
allowedProductIdsUpdates.allowed_product_ids_live = liveAllowedProductIds;
}
if (env === AppEnv.Sandbox && sandboxAllowedProductIds !== undefined) {
allowedProductIdsUpdates.allowed_product_ids_sandbox =
sandboxAllowedProductIds;
}
await OrgService.update({
db,
orgId: org.id,
@@ -95,24 +150,8 @@ export const handleUpsertVercelConfig = createRoute({
...org.processor_configs,
vercel: {
...existingVercelConfig,
// Live fields
...(body.client_integration_id
? { client_integration_id: body.client_integration_id }
: {}),
...(body.client_secret
? { client_secret: body.client_secret }
: {}),
...(body.webhook_url ? { webhook_url: body.webhook_url } : {}),
// Sandbox fields
...(body.sandbox_client_id
? { sandbox_client_id: body.sandbox_client_id }
: {}),
...(body.sandbox_client_secret
? { sandbox_client_secret: body.sandbox_client_secret }
: {}),
...(body.sandbox_webhook_url
? { sandbox_webhook_url: body.sandbox_webhook_url }
: {}),
...allowedProductIdsUpdates,
...envSpecificClientConfig,
// Custom payment method (for both envs)
...(customPaymentMethod
? { custom_payment_method: customPaymentMethod }

View File

@@ -245,6 +245,9 @@ export const createOrgResponse = ({
webhook_url: vercelConnection.webhook_url,
custom_payment_method: vercelConnection.custom_payment_method,
marketplace_mode: vercelConnection.marketplace_mode,
allowed_product_ids_live: vercelConnection.allowed_product_ids_live,
allowed_product_ids_sandbox:
vercelConnection.allowed_product_ids_sandbox,
},
revenuecat: {
connected: revenueCatConnection.connected,

View File

@@ -7,32 +7,26 @@ import chalk from "chalk";
/**
* Test: Attach free default product, then attach pro with invoice mode
*/
test.concurrent(`${chalk.yellowBright("invoice-mode: free default then pro with invoice checkout")}`, async () => {
const users = items.monthlyUsers({ includedUsage: 1 });
const free = products.base({
id: "free",
items: [items.monthlyMessages({ includedUsage: 100 }), users],
});
test.concurrent(`${chalk.yellowBright("attach: pro plan with failed payment method")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 100 }), users],
});
const premium = products.premium({
id: "premium",
items: [items.monthlyMessages({ includedUsage: 100 }), users],
items: [messagesItem],
});
const { autumnV1 } = await initScenario({
customerId: "test",
customerId: "test-failed-pm",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [free, pro, premium] }),
s.customer({ paymentMethod: "fail" }), // Failed payment method
s.products({ list: [pro] }),
],
actions: [],
});
await autumnV1.attach({
customer_id: "test",
const result = await autumnV1.attach({
customer_id: "test-failed-pm",
product_id: pro.id,
});
console.log("Attach response:", JSON.stringify(result, null, 2));
});

View File

@@ -0,0 +1,794 @@
/**
* Scheduled Switch Discount Tests (Attach V2)
*
* Tests that discounts (coupons) applied to a Stripe subscription are preserved
* when a plan is downgraded via scheduled switch.
*
* The bug: When creating subscription schedule phases in buildStripePhasesUpdate,
* the `discounts` parameter is NOT set on the phases. This means when the subscription
* transitions to the next phase at billing cycle end, discounts are lost.
*
* Key behaviors tested:
* - Percent-off discount persists after scheduling a downgrade
* - Amount-off discount persists after scheduling a downgrade
* - Discount persists after advancing cycle (phase transition)
* - Discount survives when replacing a scheduled downgrade with another
* - Multiple discounts survive scheduling
* - Discount survives upgrade that cancels a scheduled downgrade
*/
import { expect, test } from "bun:test";
import type { ApiCustomerV3 } from "@autumn/shared";
import {
applySubscriptionDiscount,
createAmountCoupon,
createPercentCoupon,
getStripeSubscription,
} from "@tests/integration/billing/utils/discounts/discountTestUtils";
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
import {
expectCustomerProducts,
expectProductCanceling,
expectProductScheduled,
} from "@tests/integration/billing/utils/expectCustomerProductCorrect";
import { TestFeature } from "@tests/setup/v2Features";
import { items } from "@tests/utils/fixtures/items";
import { products } from "@tests/utils/fixtures/products";
import { advanceToNextInvoice } from "@tests/utils/testAttachUtils/testAttachUtils";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
/**
* Helper to extract coupon ID from a Stripe discount object.
* Handles both string and expanded object forms.
*/
const extractCouponId = (discount: unknown): string | null => {
if (typeof discount === "string") return discount;
if (
discount &&
typeof discount === "object" &&
"source" in discount &&
discount.source &&
typeof discount.source === "object" &&
"coupon" in discount.source &&
discount.source.coupon &&
typeof discount.source.coupon === "object" &&
"id" in discount.source.coupon
) {
return discount.source.coupon.id as string;
}
return null;
};
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 1: Premium to Pro with 20% discount - verify discount on sub after schedule
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer has premium ($50/mo) with 20% off coupon on subscription
* - Downgrade to pro ($20/mo) - scheduled for end of cycle
*
* Expected Result:
* - Discount still present on Stripe subscription after scheduling the downgrade
* - Premium is canceling, pro is scheduled
*/
test.concurrent(`${chalk.yellowBright("scheduled-switch-discounts 1: 20% discount preserved after scheduling downgrade")}`, async () => {
const customerId = "sched-switch-discount-20pct";
const proMessagesItem = items.monthlyMessages({ includedUsage: 500 });
const pro = products.pro({
id: "pro",
items: [proMessagesItem],
});
const premiumMessagesItem = items.monthlyMessages({
includedUsage: 1000,
});
const premium = products.premium({
id: "premium",
items: [premiumMessagesItem],
});
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, premium] }),
],
actions: [s.billing.attach({ productId: premium.id })],
});
// Apply 20% discount to the subscription
const { stripeCli, subscription: subBefore } = await getStripeSubscription({
customerId,
});
const coupon = await createPercentCoupon({
stripeCli,
percentOff: 20,
});
await applySubscriptionDiscount({
stripeCli,
subscriptionId: subBefore.id,
couponIds: [coupon.id],
});
// Verify discount is applied before downgrade
const subWithDiscount = await stripeCli.subscriptions.retrieve(subBefore.id, {
expand: ["discounts.source.coupon"],
});
expect(subWithDiscount.discounts?.length).toBeGreaterThanOrEqual(1);
// Schedule downgrade to pro
await autumnV1.billing.attach({
customer_id: customerId,
product_id: pro.id,
redirect_mode: "if_required",
});
// Verify product states
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductCanceling({
customer,
productId: premium.id,
});
await expectProductScheduled({
customer,
productId: pro.id,
});
// Verify discount is still on the subscription after scheduling the downgrade
const { subscription: subAfter } = await getStripeSubscription({
customerId,
});
const subAfterExpanded = await stripeCli.subscriptions.retrieve(subAfter.id, {
expand: ["discounts.source.coupon"],
});
// KEY ASSERTION: discount should still be present
expect(subAfterExpanded.discounts?.length).toBeGreaterThanOrEqual(1);
expect(extractCouponId(subAfterExpanded.discounts?.[0])).toBe(coupon.id);
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 2: Premium to Pro with $10 off discount - verify discount after schedule
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer has premium ($50/mo) with $10 off coupon on subscription
* - Downgrade to pro ($20/mo) - scheduled for end of cycle
*
* Expected Result:
* - Amount-off discount still present on subscription after scheduling
*/
test.concurrent(`${chalk.yellowBright("scheduled-switch-discounts 2: $10 off discount preserved after scheduling downgrade")}`, async () => {
const customerId = "sched-switch-discount-10off";
const proMessagesItem = items.monthlyMessages({ includedUsage: 500 });
const pro = products.pro({
id: "pro",
items: [proMessagesItem],
});
const premiumMessagesItem = items.monthlyMessages({
includedUsage: 1000,
});
const premium = products.premium({
id: "premium",
items: [premiumMessagesItem],
});
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, premium] }),
],
actions: [s.billing.attach({ productId: premium.id })],
});
// Apply $10 off discount to the subscription
const { stripeCli, subscription: subBefore } = await getStripeSubscription({
customerId,
});
const coupon = await createAmountCoupon({
stripeCli,
amountOffCents: 1000,
});
await applySubscriptionDiscount({
stripeCli,
subscriptionId: subBefore.id,
couponIds: [coupon.id],
});
// Schedule downgrade to pro
await autumnV1.billing.attach({
customer_id: customerId,
product_id: pro.id,
redirect_mode: "if_required",
});
// Verify product states
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductCanceling({
customer,
productId: premium.id,
});
await expectProductScheduled({
customer,
productId: pro.id,
});
// Verify discount is still on the subscription
const { subscription: subAfter } = await getStripeSubscription({
customerId,
});
const subAfterExpanded = await stripeCli.subscriptions.retrieve(subAfter.id, {
expand: ["discounts.source.coupon"],
});
expect(subAfterExpanded.discounts?.length).toBeGreaterThanOrEqual(1);
expect(extractCouponId(subAfterExpanded.discounts?.[0])).toBe(coupon.id);
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 3: Premium to Pro with discount - advance cycle, verify discount survives
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer has premium ($50/mo) with 20% off coupon
* - Downgrade to pro ($20/mo) - scheduled
* - Advance test clock to next billing cycle
*
* Expected Result:
* - After cycle: pro is active, premium removed
* - Discount should STILL be on the subscription after the phase transition
*
* THIS TEST EXPOSES THE BUG: subscription schedule phases don't carry discounts,
* so when the subscription transitions to the pro phase, the discount is lost.
*/
test.concurrent(`${chalk.yellowBright("scheduled-switch-discounts 3: 20% discount preserved after cycle advance")}`, async () => {
const customerId = "sched-switch-discount-cycle";
const proMessagesItem = items.monthlyMessages({ includedUsage: 500 });
const pro = products.pro({
id: "pro",
items: [proMessagesItem],
});
const premiumMessagesItem = items.monthlyMessages({
includedUsage: 1000,
});
const premium = products.premium({
id: "premium",
items: [premiumMessagesItem],
});
const { autumnV1, ctx, testClockId, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, premium] }),
],
actions: [s.billing.attach({ productId: premium.id })],
});
// Apply 20% discount to the subscription
const { stripeCli, subscription: subBefore } = await getStripeSubscription({
customerId,
});
const coupon = await createPercentCoupon({
stripeCli,
percentOff: 20,
});
await applySubscriptionDiscount({
stripeCli,
subscriptionId: subBefore.id,
couponIds: [coupon.id],
});
// Verify discount applied before downgrade
const subWithDiscount = await stripeCli.subscriptions.retrieve(subBefore.id, {
expand: ["discounts.source.coupon"],
});
expect(subWithDiscount.discounts?.length).toBeGreaterThanOrEqual(1);
// Schedule downgrade to pro
await autumnV1.billing.attach({
customer_id: customerId,
product_id: pro.id,
redirect_mode: "if_required",
});
// Verify discount still present after scheduling
const { subscription: subMid } = await getStripeSubscription({
customerId,
});
const subMidExpanded = await stripeCli.subscriptions.retrieve(subMid.id, {
expand: ["discounts.source.coupon"],
});
expect(subMidExpanded.discounts?.length).toBeGreaterThanOrEqual(1);
// Advance to next billing cycle (discount is still on the subscription)
await advanceToNextInvoice({
stripeCli: ctx.stripeCli,
testClockId: testClockId!,
currentEpochMs: advancedTo,
withPause: true,
});
const customerAfterCycle =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Verify pro is active, premium removed
await expectCustomerProducts({
customer: customerAfterCycle,
active: [pro.id],
notPresent: [premium.id],
});
// Verify features updated to pro tier
expectCustomerFeatureCorrect({
customer: customerAfterCycle,
featureId: TestFeature.Messages,
includedUsage: 500,
balance: 500,
usage: 0,
});
// KEY BUG CHECK: verify discount survives the phase transition
const { subscription: subAfterCycle } = await getStripeSubscription({
customerId,
});
const subAfterExpanded = await stripeCli.subscriptions.retrieve(
subAfterCycle.id,
{ expand: ["discounts.source.coupon"] },
);
// The discount should still be present after the scheduled switch completed
expect(subAfterExpanded.discounts?.length).toBeGreaterThanOrEqual(1);
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 4: Premium to Pro (scheduled) to Free (replace) - discount preserved
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer has premium ($50/mo) with 20% off coupon
* - Downgrade to pro ($20/mo) - scheduled
* - Replace scheduled with free - re-schedules
*
* Expected Result:
* - Discount still on subscription after replacing the scheduled downgrade
*
* The schedule is released and recreated during replacement. This test verifies
* that the discount survives the release + recreate cycle.
*/
test.concurrent(`${chalk.yellowBright("scheduled-switch-discounts 4: discount preserved when replacing scheduled downgrade")}`, async () => {
const customerId = "sched-switch-discount-replace";
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const free = products.base({
id: "free",
items: [messagesItem],
});
const proMessagesItem = items.monthlyMessages({ includedUsage: 500 });
const pro = products.pro({
id: "pro",
items: [proMessagesItem],
});
const premiumMessagesItem = items.monthlyMessages({
includedUsage: 1000,
});
const premium = products.premium({
id: "premium",
items: [premiumMessagesItem],
});
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [free, pro, premium] }),
],
actions: [s.billing.attach({ productId: premium.id })],
});
// Apply 20% discount to the subscription
const { stripeCli, subscription: subBefore } = await getStripeSubscription({
customerId,
});
const coupon = await createPercentCoupon({
stripeCli,
percentOff: 20,
});
await applySubscriptionDiscount({
stripeCli,
subscriptionId: subBefore.id,
couponIds: [coupon.id],
});
// Schedule downgrade to pro
await autumnV1.billing.attach({
customer_id: customerId,
product_id: pro.id,
redirect_mode: "if_required",
});
// Verify mid-state
const customerMid = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductCanceling({
customer: customerMid,
productId: premium.id,
});
await expectProductScheduled({
customer: customerMid,
productId: pro.id,
});
// Replace scheduled pro with free
await autumnV1.billing.attach({
customer_id: customerId,
product_id: free.id,
redirect_mode: "if_required",
});
// Verify product states after replacement
const customerAfterReplace =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductCanceling({
customer: customerAfterReplace,
productId: premium.id,
});
await expectProductScheduled({
customer: customerAfterReplace,
productId: free.id,
});
// Verify discount is still on the subscription after schedule replacement
const { subscription: subAfterReplace } = await getStripeSubscription({
customerId,
});
const subAfterExpanded = await stripeCli.subscriptions.retrieve(
subAfterReplace.id,
{ expand: ["discounts.source.coupon"] },
);
expect(subAfterExpanded.discounts?.length).toBeGreaterThanOrEqual(1);
expect(extractCouponId(subAfterExpanded.discounts?.[0])).toBe(coupon.id);
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 5: Multiple discounts preserved after scheduled downgrade
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer has premium ($50/mo) with two discounts (20% off + $5 off)
* - Downgrade to pro ($20/mo) - scheduled
*
* Expected Result:
* - Both discounts still present on subscription after scheduling
*/
test.concurrent(`${chalk.yellowBright("scheduled-switch-discounts 5: multiple discounts preserved after scheduling")}`, async () => {
const customerId = "sched-switch-discount-multi";
const proMessagesItem = items.monthlyMessages({ includedUsage: 500 });
const pro = products.pro({
id: "pro",
items: [proMessagesItem],
});
const premiumMessagesItem = items.monthlyMessages({
includedUsage: 1000,
});
const premium = products.premium({
id: "premium",
items: [premiumMessagesItem],
});
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, premium] }),
],
actions: [s.billing.attach({ productId: premium.id })],
});
// Apply two discounts to the subscription
const { stripeCli, subscription: subBefore } = await getStripeSubscription({
customerId,
});
const percentCoupon = await createPercentCoupon({
stripeCli,
percentOff: 20,
});
const amountCoupon = await createAmountCoupon({
stripeCli,
amountOffCents: 500,
});
// Apply both discounts
await applySubscriptionDiscount({
stripeCli,
subscriptionId: subBefore.id,
couponIds: [percentCoupon.id, amountCoupon.id],
});
// Verify both discounts applied
const subWithDiscounts = await stripeCli.subscriptions.retrieve(
subBefore.id,
{ expand: ["discounts.source.coupon"] },
);
expect(subWithDiscounts.discounts?.length).toBe(2);
// Schedule downgrade to pro
await autumnV1.billing.attach({
customer_id: customerId,
product_id: pro.id,
redirect_mode: "if_required",
});
// Verify product states
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductCanceling({
customer,
productId: premium.id,
});
await expectProductScheduled({
customer,
productId: pro.id,
});
// Verify BOTH discounts are still on the subscription
const { subscription: subAfter } = await getStripeSubscription({
customerId,
});
const subAfterExpanded = await stripeCli.subscriptions.retrieve(subAfter.id, {
expand: ["discounts.source.coupon"],
});
expect(subAfterExpanded.discounts?.length).toBe(2);
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 6: Upgrade from scheduled downgrade - discount preserved
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer has premium ($50/mo) with 20% off coupon
* - Downgrade to pro ($20/mo) - scheduled
* - Upgrade to ultra ($200/mo) - immediate, cancels schedule
*
* Expected Result:
* - Discount should still be on the subscription after upgrade cancels the schedule
* - Ultra is active, premium and pro are removed
*/
test.concurrent(`${chalk.yellowBright("scheduled-switch-discounts 6: discount preserved after upgrade cancels scheduled downgrade")}`, async () => {
const customerId = "sched-switch-discount-upgrade";
const proMessagesItem = items.monthlyMessages({ includedUsage: 500 });
const pro = products.pro({
id: "pro",
items: [proMessagesItem],
});
const premiumMessagesItem = items.monthlyMessages({
includedUsage: 1000,
});
const premium = products.premium({
id: "premium",
items: [premiumMessagesItem],
});
const ultraMessagesItem = items.monthlyMessages({
includedUsage: 5000,
});
const ultra = products.ultra({
id: "ultra",
items: [ultraMessagesItem],
});
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, premium, ultra] }),
],
actions: [
s.billing.attach({ productId: premium.id }),
s.billing.attach({ productId: pro.id }),
],
});
// Apply 20% discount to the subscription
const { stripeCli, subscription: subBefore } = await getStripeSubscription({
customerId,
});
const coupon = await createPercentCoupon({
stripeCli,
percentOff: 20,
});
await applySubscriptionDiscount({
stripeCli,
subscriptionId: subBefore.id,
couponIds: [coupon.id],
});
// Verify scheduled state
const customerBefore =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductCanceling({
customer: customerBefore,
productId: premium.id,
});
await expectProductScheduled({
customer: customerBefore,
productId: pro.id,
});
// Upgrade to ultra (should cancel scheduled downgrade)
await autumnV1.billing.attach({
customer_id: customerId,
product_id: ultra.id,
redirect_mode: "if_required",
});
// Verify ultra is active, premium and pro removed
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerProducts({
customer,
active: [ultra.id],
notPresent: [premium.id, pro.id],
});
// Verify discount is still on the subscription after upgrade
const { subscription: subAfter } = await getStripeSubscription({
customerId,
});
const subAfterExpanded = await stripeCli.subscriptions.retrieve(subAfter.id, {
expand: ["discounts.source.coupon"],
});
expect(subAfterExpanded.discounts?.length).toBeGreaterThanOrEqual(1);
expect(extractCouponId(subAfterExpanded.discounts?.[0])).toBe(coupon.id);
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 7: Repeating coupon duration preserved across phase transition
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer has premium ($50/mo) with 3-month repeating 20% off coupon
* - Downgrade to pro ($20/mo) - scheduled
* - Advance test clock past one billing cycle
*
* Expected Result:
* - After cycle: pro is active with discount still present
* - The discount's `end` timestamp should be the SAME as the original
* (not reset to phase2_start + 3 months)
* - This means if 1 month was used on premium, only 2 months remain on pro
*
* This test catches the bug where using `coupon: couponId` on phases
* creates a fresh discount with a reset duration, instead of using
* `discount: discountId` to preserve the original duration.
*/
test.concurrent(`${chalk.yellowBright("scheduled-switch-discounts 7: repeating coupon duration preserved across phase transition")}`, async () => {
const customerId = "sched-switch-discount-duration";
const proMessagesItem = items.monthlyMessages({ includedUsage: 500 });
const pro = products.pro({
id: "pro",
items: [proMessagesItem],
});
const premiumMessagesItem = items.monthlyMessages({
includedUsage: 1000,
});
const premium = products.premium({
id: "premium",
items: [premiumMessagesItem],
});
const { autumnV1, ctx, testClockId, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, premium] }),
],
actions: [s.billing.attach({ productId: premium.id })],
});
// Create a 3-month repeating coupon and apply to subscription
const { stripeCli, subscription: subBefore } = await getStripeSubscription({
customerId,
});
const coupon = await createPercentCoupon({
stripeCli,
percentOff: 20,
duration: "repeating",
durationInMonths: 3,
});
await applySubscriptionDiscount({
stripeCli,
subscriptionId: subBefore.id,
couponIds: [coupon.id],
});
// Record the original discount's end timestamp
const subWithDiscount = await stripeCli.subscriptions.retrieve(subBefore.id, {
expand: ["discounts.source.coupon"],
});
expect(subWithDiscount.discounts?.length).toBeGreaterThanOrEqual(1);
const originalDiscount = subWithDiscount.discounts?.[0];
expect(originalDiscount).toBeDefined();
const originalDiscountEnd =
typeof originalDiscount !== "string" ? originalDiscount?.end : null;
expect(originalDiscountEnd).not.toBeNull();
// Schedule downgrade to pro
await autumnV1.billing.attach({
customer_id: customerId,
product_id: pro.id,
redirect_mode: "if_required",
});
// Advance to next billing cycle
await advanceToNextInvoice({
stripeCli: ctx.stripeCli,
testClockId: testClockId!,
currentEpochMs: advancedTo,
withPause: true,
});
// Verify pro is now active
const customerAfterCycle =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerProducts({
customer: customerAfterCycle,
active: [pro.id],
notPresent: [premium.id],
});
// KEY ASSERTION: discount still present AND end timestamp is preserved
const { subscription: subAfterCycle } = await getStripeSubscription({
customerId,
});
const subAfterExpanded = await stripeCli.subscriptions.retrieve(
subAfterCycle.id,
{ expand: ["discounts.source.coupon"] },
);
expect(subAfterExpanded.discounts?.length).toBeGreaterThanOrEqual(1);
const discountAfterCycle = subAfterExpanded.discounts?.[0];
const discountEndAfterCycle =
typeof discountAfterCycle !== "string" ? discountAfterCycle?.end : null;
// The discount end should be the SAME as the original - not reset
// If it was reset, it would be ~phase2_start + 3 months (much later)
expect(discountEndAfterCycle).toBe(originalDiscountEnd);
});

View File

@@ -0,0 +1,139 @@
/**
* Integration tests for customer.products.updated webhook via UPDATE SUBSCRIPTION endpoint.
* Uses autumnV1.subscriptions.update() which calls handleUpdateSubscription.
*
* Verifies that webhooks are sent correctly for:
* - Update with price change: scenario "upgrade" (new customer product created)
* - Update with feature change only: scenario "upgrade"
* - Trial removal: scenario "upgrade" (trial ends, new billing cycle starts)
*
* Uses Svix Play (https://www.svix.com/play/) to receive and verify webhooks.
*/
import { afterAll, beforeAll, expect, test } from "bun:test";
import type { ApiCustomerV3, ApiProduct } from "@autumn/shared";
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
import { expectProductActive } from "@tests/integration/billing/utils/expectCustomerProductCorrect";
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 {
generatePlayToken,
getPlayWebhookUrl,
waitForWebhook,
} from "./utils/svixPlayClient.js";
import {
createTestEndpoint,
deleteTestEndpoint,
} from "./utils/svixTestEndpoint.js";
type CustomerProductsUpdatedPayload = {
type: string;
data: {
scenario: string;
customer: ApiCustomerV3;
updated_product: ApiProduct;
};
};
// ═══════════════════════════════════════════════════════════════════════════════
// SVIX PLAY SETUP (shared across all tests)
// ═══════════════════════════════════════════════════════════════════════════════
let playToken: string;
let endpointId: string;
beforeAll(async () => {
// 1. Generate Svix Play token
playToken = await generatePlayToken();
console.log(`Generated Svix Play token: ${playToken}`);
// 2. Get org's Svix app ID
const svixAppId = ctx.org.svix_config?.sandbox_app_id;
if (!svixAppId) {
throw new Error(
"Test org does not have svix_config.sandbox_app_id configured. " +
"Cannot run webhook integration tests without Svix app.",
);
}
// 3. Create Svix endpoint pointing to Svix Play
const playUrl = getPlayWebhookUrl(playToken);
console.log(`Creating Svix endpoint: ${playUrl}`);
endpointId = await createTestEndpoint({ appId: svixAppId, playUrl });
console.log(`Created Svix endpoint: ${endpointId}`);
});
afterAll(async () => {
// Cleanup: delete Svix endpoint
const svixAppId = ctx.org.svix_config?.sandbox_app_id;
if (svixAppId && endpointId) {
await deleteTestEndpoint({ appId: svixAppId, endpointId });
console.log(`Deleted Svix endpoint: ${endpointId}`);
}
});
// ═══════════════════════════════════════════════════════════════════════════════
// UPDATE SUBSCRIPTION TESTS
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("webhook update-sub: increase included usage - scenario: upgrade")}`, async () => {
const customerId = "webhook-update-usage";
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const priceItem = items.monthlyPrice({ price: 20 });
const pro = products.base({
id: "pro",
items: [messagesItem, priceItem],
});
// Setup: customer with Pro ($20/month, 100 messages) attached
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success", skipWebhooks: true }),
s.products({ list: [pro] }),
],
actions: [s.attach({ productId: pro.id })],
});
// Action: update subscription to increase included usage from 100 to 200 (same price)
const newMessagesItem = items.monthlyMessages({ includedUsage: 200 });
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: pro.id,
items: [newMessagesItem, priceItem],
});
// Assert: webhook received with scenario "upgrade"
const result = await waitForWebhook<CustomerProductsUpdatedPayload>({
token: playToken,
predicate: (payload) =>
payload.type === "customer.products.updated" &&
payload.data?.customer?.id === customerId &&
payload.data?.scenario === "upgrade" &&
payload.data?.updated_product?.id === pro.id,
timeoutMs: 15000,
});
expect(result).not.toBeNull();
expect(result?.payload.type).toBe("customer.products.updated");
const { data } = result!.payload;
expect(data.scenario).toBe("upgrade");
expect(data.updated_product.id).toBe(pro.id);
expect(data.customer.id).toBe(customerId);
// Verify customer state: Pro is active with new features
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductActive({ customer, productId: pro.id });
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
balance: 200,
});
});

View File

@@ -53,6 +53,8 @@ export const VercelProcessorConfigSchema = z.object({
sandbox_client_secret: z.string().optional(),
sandbox_webhook_url: z.string().optional(),
webhook_url: z.string(),
allowed_product_ids_live: z.array(z.string().min(1)).optional(),
allowed_product_ids_sandbox: z.array(z.string().min(1)).optional(),
custom_payment_method: z
.object({
live: z.string().optional(),
@@ -78,6 +80,8 @@ export const UpsertVercelProcessorConfigSchema = z.object({
sandbox_client_id: z.string().min(8).optional(),
sandbox_client_secret: z.string().min(8).optional(),
sandbox_webhook_url: z.string().min(14).optional(),
allowed_product_ids_live: z.array(z.string().min(1)).optional(),
allowed_product_ids_sandbox: z.array(z.string().min(1)).optional(),
custom_payment_method: z
.object({
live: z.string().min(8).optional(),

View File

@@ -35,6 +35,8 @@ export const FrontendOrgSchema = z.object({
webhook_url: z.string().optional(),
custom_payment_method: z.string().optional(),
marketplace_mode: z.enum(VercelMarketplaceMode).optional(),
allowed_product_ids_live: z.array(z.string()).optional(),
allowed_product_ids_sandbox: z.array(z.string()).optional(),
}),
revenuecat: z.object({
connected: z.boolean(),

View File

@@ -3,7 +3,7 @@ import type {
VercelMarketplaceMode,
} from "@autumn/shared";
import type { AxiosInstance } from "axios";
import { useState } from "react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { AppPortal } from "svix-react";
import { Button } from "@/components/v2/buttons/Button";
@@ -24,8 +24,10 @@ import {
} from "@/components/v2/cards/Card";
import { FormLabel } from "@/components/v2/form/FormLabel";
import { Input } from "@/components/v2/inputs/Input";
import { TagSelect } from "@/components/v2/selects/TagSelect";
import { useTheme } from "@/contexts/ThemeProvider";
import { useOrg } from "@/hooks/common/useOrg";
import { useProductsQuery } from "@/hooks/queries/useProductsQuery";
import { useVercelQuery } from "@/hooks/queries/useVercelQuery";
import { OrgService } from "@/services/OrgService";
import { useAxiosInstance } from "@/services/useAxiosInstance";
@@ -33,6 +35,16 @@ import { useEnv } from "@/utils/envUtils";
import { getBackendErr } from "@/utils/genUtils";
import LoadingScreen from "@/views/general/LoadingScreen";
type VercelConfigState = {
client_integration_id: string;
client_secret: string;
webhook_url: string;
custom_payment_method: string;
marketplace_mode: VercelMarketplaceMode;
allowed_product_ids_live: string[];
allowed_product_ids_sandbox: string[];
};
export const ConfigureVercel = () => {
const { org, isLoading, mutate } = useOrg();
const {
@@ -40,33 +52,71 @@ export const ConfigureVercel = () => {
isLoading: isVercelLoading,
error: vercelError,
} = useVercelQuery();
const { products } = useProductsQuery();
const env = useEnv();
const axiosInstance = useAxiosInstance();
const [vercelConfig, setVercelConfig] = useState({
const [vercelConfig, setVercelConfig] = useState<VercelConfigState>({
client_integration_id: "",
client_secret: "",
webhook_url: "",
custom_payment_method: "",
marketplace_mode: "" as VercelMarketplaceMode,
allowed_product_ids_live: [],
allowed_product_ids_sandbox: [],
});
const activeAllowedProductIds =
env === "live"
? vercelConfig.allowed_product_ids_live
: vercelConfig.allowed_product_ids_sandbox;
const setActiveAllowedProductIds = (values: string[]) => {
setVercelConfig((prev) => ({
...prev,
...(env === "live"
? { allowed_product_ids_live: values }
: { allowed_product_ids_sandbox: values }),
}));
};
useEffect(() => {
const vercelOrgConfig = org?.processor_configs?.vercel;
if (!vercelOrgConfig) {
return;
}
setVercelConfig((prev) => ({
...prev,
allowed_product_ids_live:
vercelOrgConfig.allowed_product_ids_live ??
[],
allowed_product_ids_sandbox:
vercelOrgConfig.allowed_product_ids_sandbox ?? [],
}));
}, [org]);
const productOptions = products
.slice()
.sort((a, b) => a.name.localeCompare(b.name))
.map((product) => ({
label: `${product.name} (${product.id})`,
value: product.id,
}));
const currentClientIntegrationId = org?.processor_configs?.vercel?.client_integration_id
const currentClientSecret = org?.processor_configs?.vercel?.client_secret
const currentWebhookUrl = org?.processor_configs?.vercel?.webhook_url
const currentCustomPaymentMethod = org?.processor_configs?.vercel?.custom_payment_method
const { isDark } = useTheme();
const handleSaveVercelConfig = async (
axiosInstance: AxiosInstance,
vercelConfig: {
client_integration_id?: string;
client_secret?: string;
webhook_url?: string;
custom_payment_method?: string;
marketplace_mode?: VercelMarketplaceMode;
},
vercelConfig: VercelConfigState,
) => {
try {
// Map generic field names to env-specific field names
const filteredConfig: UpsertVercelProcessorConfig = {};
// Map to correct field names based on current env
if (vercelConfig.client_integration_id?.trim()) {
if (env === "live") {
filteredConfig.client_integration_id =
@@ -90,7 +140,8 @@ export const ConfigureVercel = () => {
if (env === "live") {
filteredConfig.webhook_url = vercelConfig.webhook_url.trim();
} else {
filteredConfig.sandbox_webhook_url = vercelConfig.webhook_url.trim();
filteredConfig.sandbox_webhook_url =
vercelConfig.webhook_url.trim();
}
}
@@ -104,6 +155,14 @@ export const ConfigureVercel = () => {
filteredConfig.marketplace_mode = vercelConfig.marketplace_mode;
}
if (env === "live") {
filteredConfig.allowed_product_ids_live =
vercelConfig.allowed_product_ids_live;
} else {
filteredConfig.allowed_product_ids_sandbox =
vercelConfig.allowed_product_ids_sandbox;
}
const res = await OrgService.upsertVercelConfig(
axiosInstance,
filteredConfig,
@@ -111,15 +170,15 @@ export const ConfigureVercel = () => {
if (res.status === 200) {
toast.success("Vercel config updated successfully");
await mutate();
// Clear the form after successful update
setVercelConfig({
// Clear credential fields, keep product filters
setVercelConfig((prev) => ({
...prev,
client_integration_id: "",
client_secret: "",
webhook_url: "",
custom_payment_method: "",
marketplace_mode:
filteredConfig.marketplace_mode as VercelMarketplaceMode,
});
marketplace_mode: filteredConfig.marketplace_mode as VercelMarketplaceMode,
}));
} else {
toast.error("Failed to update Vercel config");
}
@@ -158,13 +217,13 @@ export const ConfigureVercel = () => {
<Input
value={vercelConfig.client_integration_id || ""}
onChange={(e) =>
setVercelConfig({
...vercelConfig,
setVercelConfig((prev) => ({
...prev,
client_integration_id: e.target.value,
})
}))
}
placeholder={
org?.processor_configs?.vercel?.client_integration_id ||
currentClientIntegrationId ||
"eg. oac_2ttbjWcOQ0pyH1v9wYkROKB3"
}
/>
@@ -176,17 +235,34 @@ export const ConfigureVercel = () => {
<Input
value={vercelConfig.client_secret || ""}
onChange={(e) =>
setVercelConfig({
...vercelConfig,
setVercelConfig((prev) => ({
...prev,
client_secret: e.target.value,
})
}))
}
placeholder={
org?.processor_configs?.vercel?.client_secret ||
currentClientSecret ||
"eg. VAxvZFz8ST4d5b9pa2EuXkWG"
}
/>
</div>
<div>
<FormLabel className="mb-1">
<span className="text-t2">Webhook URL</span>
</FormLabel>
<Input
value={vercelConfig.webhook_url || ""}
onChange={(e) =>
setVercelConfig((prev) => ({
...prev,
webhook_url: e.target.value,
}))
}
placeholder={
currentWebhookUrl || "eg. https://example.com/api/webhooks"
}
/>
</div>
<div>
<FormLabel className="mb-1">
<span className="text-t2">
@@ -208,17 +284,77 @@ export const ConfigureVercel = () => {
<Input
value={vercelConfig.custom_payment_method || ""}
onChange={(e) =>
setVercelConfig({
...vercelConfig,
setVercelConfig((prev) => ({
...prev,
custom_payment_method: e.target.value,
})
}))
}
placeholder={
org?.processor_configs?.vercel?.custom_payment_method ||
currentCustomPaymentMethod ||
"eg. cpmt_Yij7OBT6Fxu0UOa12XguA0vGB"
}
/>
</div>
<div className="col-span-2">
<FormLabel className="mb-1">
<span className="text-t2">Allowed Product IDs (Optional)</span>
</FormLabel>
<p className="text-t3 text-sm mb-2">
Optional: choose product IDs to show in Vercel plans.
</p>
<TagSelect
value={activeAllowedProductIds}
onChange={setActiveAllowedProductIds}
options={productOptions}
placeholder="Select product IDs to include"
showAllProducts={activeAllowedProductIds.length === 0}
renderContent={(setOpen) => (
<>
{productOptions.length === 0 ? (
<div className="p-2 text-t6">No products found</div>
) : (
productOptions.map((option) => {
const isSelected =
activeAllowedProductIds.includes(option.value);
return (
<div
key={option.value}
role="button"
tabIndex={0}
className="relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 px-2 text-sm outline-none hover:bg-accent hover:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
onClick={() => {
const nextValue = isSelected
? activeAllowedProductIds.filter(
(id) => id !== option.value,
)
: [...activeAllowedProductIds, option.value];
setActiveAllowedProductIds(nextValue);
setOpen(false);
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
const nextValue = isSelected
? activeAllowedProductIds.filter(
(id) => id !== option.value,
)
: [...activeAllowedProductIds, option.value];
setActiveAllowedProductIds(nextValue);
setOpen(false);
}
}}
>
<div className="flex items-center justify-between w-full">
<span className="truncate">{option.label}</span>
</div>
</div>
);
})
)}
</>
)}
/>
</div>
</div>
<div className="flex gap-2 mt-2">