diff --git a/server/src/cron/invoiceCron/runInvoiceCron.ts b/server/src/cron/invoiceCron/runInvoiceCron.ts index 0b4f6946e..35df40c14 100644 --- a/server/src/cron/invoiceCron/runInvoiceCron.ts +++ b/server/src/cron/invoiceCron/runInvoiceCron.ts @@ -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, diff --git a/server/src/external/vercel/handlers/handleListBillingPlans.ts b/server/src/external/vercel/handlers/handleListBillingPlans.ts index fe4ce81d6..27c19a7f5 100644 --- a/server/src/external/vercel/handlers/handleListBillingPlans.ts +++ b/server/src/external/vercel/handlers/handleListBillingPlans.ts @@ -1,5 +1,5 @@ import { - type AppEnv, + AppEnv, type FullProduct, formatAmount, getProductItemDisplay, @@ -148,11 +148,17 @@ const listVercelPlansForOrg = async ({ metadata?: Record; 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 }); diff --git a/server/src/external/vercel/handlers/installations/handleGetInstallation.ts b/server/src/external/vercel/handlers/installations/handleGetInstallation.ts index fa29cd0fc..1e7204943 100644 --- a/server/src/external/vercel/handlers/installations/handleGetInstallation.ts +++ b/server/src/external/vercel/handlers/installations/handleGetInstallation.ts @@ -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) diff --git a/server/src/internal/billing/v2/actions/updateSubscription/setup/setupDefaultProductContext.ts b/server/src/internal/billing/v2/actions/updateSubscription/setup/setupDefaultProductContext.ts index 043acd6ea..3b746389e 100644 --- a/server/src/internal/billing/v2/actions/updateSubscription/setup/setupDefaultProductContext.ts +++ b/server/src/internal/billing/v2/actions/updateSubscription/setup/setupDefaultProductContext.ts @@ -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, diff --git a/server/src/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionScheduleAction.ts b/server/src/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionScheduleAction.ts index de8a8e699..a7a0618f3 100644 --- a/server/src/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionScheduleAction.ts +++ b/server/src/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionScheduleAction.ts @@ -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, }); /** diff --git a/server/src/internal/billing/v2/providers/stripe/utils/subscriptionSchedules/buildStripePhasesUpdate.ts b/server/src/internal/billing/v2/providers/stripe/utils/subscriptionSchedules/buildStripePhasesUpdate.ts index 4942e9ce4..e35be4717 100644 --- a/server/src/internal/billing/v2/providers/stripe/utils/subscriptionSchedules/buildStripePhasesUpdate.ts +++ b/server/src/internal/billing/v2/providers/stripe/utils/subscriptionSchedules/buildStripePhasesUpdate.ts @@ -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 diff --git a/server/src/internal/orgs/handlers/handleVercelConfig.ts b/server/src/internal/orgs/handlers/handleVercelConfig.ts index 6d11292da..dcb8696f3 100644 --- a/server/src/internal/orgs/handlers/handleVercelConfig.ts +++ b/server/src/internal/orgs/handlers/handleVercelConfig.ts @@ -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 = {}; + + 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 = {}; + 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 } diff --git a/server/src/internal/orgs/orgUtils.ts b/server/src/internal/orgs/orgUtils.ts index 95c9192c0..efa61021a 100644 --- a/server/src/internal/orgs/orgUtils.ts +++ b/server/src/internal/orgs/orgUtils.ts @@ -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, diff --git a/server/tests/_temp/temp.test.ts b/server/tests/_temp/temp.test.ts index 40ffc45c6..55db1f454 100644 --- a/server/tests/_temp/temp.test.ts +++ b/server/tests/_temp/temp.test.ts @@ -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)); }); diff --git a/server/tests/integration/billing/attach/scheduled-switch/scheduled-switch-discounts.test.ts b/server/tests/integration/billing/attach/scheduled-switch/scheduled-switch-discounts.test.ts new file mode 100644 index 000000000..85a7d9538 --- /dev/null +++ b/server/tests/integration/billing/attach/scheduled-switch/scheduled-switch-discounts.test.ts @@ -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(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(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(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(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(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(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(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(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(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); +}); diff --git a/server/tests/integration/billing/autumn-webhooks/update-subscription-webhooks.test.ts b/server/tests/integration/billing/autumn-webhooks/update-subscription-webhooks.test.ts new file mode 100644 index 000000000..448aeaa5e --- /dev/null +++ b/server/tests/integration/billing/autumn-webhooks/update-subscription-webhooks.test.ts @@ -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({ + 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(customerId); + await expectProductActive({ customer, productId: pro.id }); + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + balance: 200, + }); +}); diff --git a/shared/models/genModels/processorSchemas.ts b/shared/models/genModels/processorSchemas.ts index b45e856e8..47ca55539 100644 --- a/shared/models/genModels/processorSchemas.ts +++ b/shared/models/genModels/processorSchemas.ts @@ -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(), diff --git a/shared/models/orgModels/frontendOrg.ts b/shared/models/orgModels/frontendOrg.ts index 7bbec071a..9b002077e 100644 --- a/shared/models/orgModels/frontendOrg.ts +++ b/shared/models/orgModels/frontendOrg.ts @@ -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(), diff --git a/vite/src/views/developer/configure-vercel/ConfigureVercel.tsx b/vite/src/views/developer/configure-vercel/ConfigureVercel.tsx index 75aed0a6b..bf3f821bc 100644 --- a/vite/src/views/developer/configure-vercel/ConfigureVercel.tsx +++ b/vite/src/views/developer/configure-vercel/ConfigureVercel.tsx @@ -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({ 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 = () => { - 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 = () => { - setVercelConfig({ - ...vercelConfig, + setVercelConfig((prev) => ({ + ...prev, client_secret: e.target.value, - }) + })) } placeholder={ - org?.processor_configs?.vercel?.client_secret || + currentClientSecret || "eg. VAxvZFz8ST4d5b9pa2EuXkWG" } /> +
+ + Webhook URL + + + setVercelConfig((prev) => ({ + ...prev, + webhook_url: e.target.value, + })) + } + placeholder={ + currentWebhookUrl || "eg. https://example.com/api/webhooks" + } + /> +
@@ -208,17 +284,77 @@ export const ConfigureVercel = () => { - setVercelConfig({ - ...vercelConfig, + setVercelConfig((prev) => ({ + ...prev, custom_payment_method: e.target.value, - }) + })) } placeholder={ - org?.processor_configs?.vercel?.custom_payment_method || + currentCustomPaymentMethod || "eg. cpmt_Yij7OBT6Fxu0UOa12XguA0vGB" } />
+
+ + Allowed Product IDs (Optional) + +

+ Optional: choose product IDs to show in Vercel plans. +

+ ( + <> + {productOptions.length === 0 ? ( +
No products found
+ ) : ( + productOptions.map((option) => { + const isSelected = + activeAllowedProductIds.includes(option.value); + return ( +
{ + 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); + } + }} + > +
+ {option.label} +
+
+ ); + }) + )} + + )} + /> +