diff --git a/server/src/external/vercel/handlers/handleListBillingPlans.ts b/server/src/external/vercel/handlers/handleListBillingPlans.ts index fe4ce81d6..9e4658f85 100644 --- a/server/src/external/vercel/handlers/handleListBillingPlans.ts +++ b/server/src/external/vercel/handlers/handleListBillingPlans.ts @@ -148,11 +148,21 @@ const listVercelPlansForOrg = async ({ metadata?: Record; canCancel?: boolean; }) => { + const allowedProductIds = + env === AppEnv.Live + ? org.processor_configs?.vercel?.allowed_product_ids_live + : org.processor_configs?.vercel?.allowed_product_ids_sandbox; + const allowedIds = + allowedProductIds !== undefined + ? allowedProductIds + : org.processor_configs?.vercel?.allowed_product_ids || []; + 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/orgs/handlers/handleVercelConfig.ts b/server/src/internal/orgs/handlers/handleVercelConfig.ts index 6d11292da..544728494 100644 --- a/server/src/internal/orgs/handlers/handleVercelConfig.ts +++ b/server/src/internal/orgs/handlers/handleVercelConfig.ts @@ -36,6 +36,9 @@ export const getVercelConfigDisplay = ({ webhook_url: undefined, custom_payment_method: undefined, marketplace_mode: undefined, + allowed_product_ids: undefined, + allowed_product_ids_live: undefined, + allowed_product_ids_sandbox: undefined, }; } @@ -64,15 +67,34 @@ export const getVercelConfigDisplay = ({ webhook_url: mask(webhookUrl, 8, 6), custom_payment_method: mask(customPaymentMethod, 5, 3), marketplace_mode: vercelConfig.marketplace_mode, + allowed_product_ids: vercelConfig.allowed_product_ids, + 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; + const allowedProductIds = + body.allowed_product_ids !== undefined + ? normalizeAllowedProductIds(body.allowed_product_ids) + : undefined; // Merge with existing processor_configs to avoid unsetting fields const existingVercelConfig = @@ -87,6 +109,18 @@ export const handleUpsertVercelConfig = createRoute({ } : undefined; + const allowedProductIdsUpdates: Partial = {}; + if (allowedProductIds !== undefined) { + allowedProductIdsUpdates.allowed_product_ids = allowedProductIds; + } + 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,6 +129,7 @@ export const handleUpsertVercelConfig = createRoute({ ...org.processor_configs, vercel: { ...existingVercelConfig, + ...allowedProductIdsUpdates, // Live fields ...(body.client_integration_id ? { client_integration_id: body.client_integration_id } diff --git a/server/src/internal/orgs/orgUtils.ts b/server/src/internal/orgs/orgUtils.ts index 95c9192c0..bd677673a 100644 --- a/server/src/internal/orgs/orgUtils.ts +++ b/server/src/internal/orgs/orgUtils.ts @@ -245,6 +245,11 @@ export const createOrgResponse = ({ webhook_url: vercelConnection.webhook_url, custom_payment_method: vercelConnection.custom_payment_method, marketplace_mode: vercelConnection.marketplace_mode, + allowed_product_ids: vercelConnection.allowed_product_ids, + 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/shared/models/genModels/processorSchemas.ts b/shared/models/genModels/processorSchemas.ts index b45e856e8..f32341788 100644 --- a/shared/models/genModels/processorSchemas.ts +++ b/shared/models/genModels/processorSchemas.ts @@ -53,6 +53,9 @@ export const VercelProcessorConfigSchema = z.object({ sandbox_client_secret: z.string().optional(), sandbox_webhook_url: z.string().optional(), webhook_url: z.string(), + allowed_product_ids: z.array(z.string().min(1)).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().optional(), @@ -78,6 +81,9 @@ 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: z.array(z.string().min(1)).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..679f3cb62 100644 --- a/shared/models/orgModels/frontendOrg.ts +++ b/shared/models/orgModels/frontendOrg.ts @@ -35,6 +35,9 @@ 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: z.array(z.string()).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..48e200c32 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,30 +52,84 @@ 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; + } + + const fallbackAllowedProductIds = vercelOrgConfig.allowed_product_ids ?? []; + + setVercelConfig((prev) => ({ + ...prev, + allowed_product_ids_live: + vercelOrgConfig.allowed_product_ids_live ?? + fallbackAllowedProductIds, + allowed_product_ids_sandbox: + vercelOrgConfig.allowed_product_ids_sandbox ?? + fallbackAllowedProductIds, + })); + }, [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 = + env === "live" + ? org?.processor_configs?.vercel?.client_integration_id + : org?.processor_configs?.vercel?.sandbox_client_id; + const currentClientSecret = + env === "live" + ? org?.processor_configs?.vercel?.client_secret + : org?.processor_configs?.vercel?.sandbox_client_secret; + const currentWebhookUrl = + env === "live" + ? org?.processor_configs?.vercel?.webhook_url + : org?.processor_configs?.vercel?.sandbox_webhook_url; + const currentCustomPaymentMethod = + env === "live" + ? org?.processor_configs?.vercel?.custom_payment_method + : 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 @@ -104,6 +170,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 +185,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 +232,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 +250,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 +299,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} +
+
+ ); + }) + )} + + )} + /> +