From 3d5d0a56cddc95c12d5fca4e7e9586466f883bd1 Mon Sep 17 00:00:00 2001 From: nams1570 Date: Sat, 18 Apr 2026 15:00:39 -0700 Subject: [PATCH 1/8] refactor/fix: remove default prod creation With the new bulldozer rework we dont support default products anymore. Users are encouraged to currently manually handle granting products to their end users. --- .../config/override/[level]/route.tsx | 40 +++++++++++ .../products/[productId]/edit/page-client.tsx | 46 ++++++------- .../payments/products/new/page-client.tsx | 67 +++++++----------- .../payments/products/pricing-section.tsx | 25 +------ .../payments/products/product-dialog.tsx | 43 +++--------- .../payments/products/product-price-row.tsx | 32 ++------- .../components/payments/product-dialog.tsx | 28 +------- .../v1/payments/block-new-purchases.test.ts | 7 +- .../api/v1/payments/switch-plans.test.ts | 69 ++++++++----------- 9 files changed, 143 insertions(+), 214 deletions(-) diff --git a/apps/backend/src/app/api/latest/internal/config/override/[level]/route.tsx b/apps/backend/src/app/api/latest/internal/config/override/[level]/route.tsx index 85de6a3cce..b76f4b6675 100644 --- a/apps/backend/src/app/api/latest/internal/config/override/[level]/route.tsx +++ b/apps/backend/src/app/api/latest/internal/config/override/[level]/route.tsx @@ -188,9 +188,49 @@ async function parseAndValidateConfig( throw new StatusError(StatusError.BadRequest, overrideError.error); } + rejectNewIncludeByDefaultProducts(migratedConfig); + return migratedConfig; } +/** + * Soft-close of the `include-by-default` product feature (deprecated in the + * bulldozer payments rework — see PR #1315). The config schema still accepts + * the value so that pre-existing configs continue to load, but new writes + * are rejected here. Any dashboard or SDK caller that tries to set + * `payments.products..prices` to `"include-by-default"` — whether via the + * nested form or the dot-notation form — will get a 400. + */ +function rejectNewIncludeByDefaultProducts(parsedConfig: unknown): void { + if (!parsedConfig || typeof parsedConfig !== "object") return; + const err = () => new StatusError( + StatusError.BadRequest, + "`include-by-default` product prices are no longer supported. Use an explicit $0 price instead.", + ); + const obj = parsedConfig as Record; + for (const [key, value] of Object.entries(obj)) { + const m = /^payments\.products\.([^.]+)(?:\.(.*))?$/.exec(key); + if (!m) continue; + const rest = m[2]; + if (!rest) { + if (value && typeof value === "object" && (value as Record).prices === "include-by-default") { + throw err(); + } + } else if (rest === "prices") { + if (value === "include-by-default") throw err(); + } + } + const payments = obj.payments as Record | undefined; + const products = payments?.products as Record | undefined; + if (products && typeof products === "object") { + for (const product of Object.values(products)) { + if (product && typeof product === "object" && (product as Record).prices === "include-by-default") { + throw err(); + } + } + } +} + async function warnOnValidationFailure( levelConfig: typeof levelConfigs[keyof typeof levelConfigs], options: { projectId: string, branchId: string, config: any }, diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/edit/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/edit/page-client.tsx index f834f927dd..c3171d19b9 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/edit/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/edit/page-client.tsx @@ -4,6 +4,9 @@ import { Link } from "@/components/link"; import { ItemDialog } from "@/components/payments/item-dialog"; import { useRouter } from "@/components/router"; import { + Alert, + AlertDescription, + AlertTitle, Button, Checkbox, Input, @@ -103,7 +106,7 @@ function EditProductForm({ productId, existingProduct }: { productId: string, ex const existingPrices = existingProduct.prices === 'include-by-default' ? {} : existingProduct.prices; - const existingFreeByDefault = existingProduct.prices === 'include-by-default'; + const wasLegacyIncludeByDefault = existingProduct.prices === 'include-by-default'; // Form state - initialized from existing product const [displayName, setDisplayName] = useState(existingProduct.displayName || ''); @@ -112,7 +115,6 @@ function EditProductForm({ productId, existingProduct }: { productId: string, ex const [isAddOnTo, setIsAddOnTo] = useState(existingIsAddOnTo); const [stackable, setStackable] = useState(existingProduct.stackable); const [serverOnly, setServerOnly] = useState(existingProduct.serverOnly); - const [freeByDefault, setFreeByDefault] = useState(existingFreeByDefault); const [prices, setPrices] = useState>(existingPrices); const [includedItems, setIncludedItems] = useState(existingProduct.includedItems); const [freeTrial, setFreeTrial] = useState(existingProduct.freeTrial); @@ -155,7 +157,7 @@ function EditProductForm({ productId, existingProduct }: { productId: string, ex productLineId: effectiveProductLineId || undefined, isAddOnTo: isAddOn ? Object.fromEntries(isAddOnTo.map(id => [id, true])) : false, stackable, - prices: freeByDefault ? 'include-by-default' : prices, + prices, includedItems, serverOnly, freeTrial, @@ -197,8 +199,8 @@ function EditProductForm({ productId, existingProduct }: { productId: string, ex } } - if (!freeByDefault && Object.keys(prices).length === 0) { - newErrors.prices = "Add at least one price or enable 'Include by default'"; + if (Object.keys(prices).length === 0) { + newErrors.prices = "Add at least one price"; } return newErrors; @@ -219,7 +221,7 @@ function EditProductForm({ productId, existingProduct }: { productId: string, ex productLineId: effectiveProductLineId || undefined, isAddOnTo: isAddOn ? Object.fromEntries(isAddOnTo.map(id => [id, true])) : false, stackable, - prices: freeByDefault ? 'include-by-default' : prices, + prices, includedItems, serverOnly, freeTrial, @@ -267,7 +269,7 @@ function EditProductForm({ productId, existingProduct }: { productId: string, ex } }; - const canSave = !!(displayName.trim() && (freeByDefault || Object.keys(prices).length > 0)); + const canSave = !!(displayName.trim() && Object.keys(prices).length > 0); return (
@@ -299,6 +301,17 @@ function EditProductForm({ productId, existingProduct }: { productId: string, ex {/* Left side - Configuration form */}
+ {wasLegacyIncludeByDefault && ( + + This product uses a deprecated pricing option + + “Include by default” is no longer supported and currently does + not grant any items to customers. Add at least one price + (e.g. $0) below and save to restore customer access. + + + )} + {/* Display Name and Product ID - same row */}
{/* Display Name */} @@ -369,23 +382,10 @@ function EditProductForm({ productId, existingProduct }: { productId: string, ex hasError={!!errors.prices} errorMessage={errors.prices} variant="form" - isFree={freeByDefault || (Object.keys(prices).length === 1 && Object.values(prices)[0].USD === '0.00')} - freeByDefault={freeByDefault} + isFree={Object.keys(prices).length === 1 && Object.values(prices)[0].USD === '0.00'} onMakeFree={() => { - setPrices({}); - setFreeByDefault(true); - }} - onMakePaid={() => { - setFreeByDefault(false); - }} - onFreeByDefaultChange={(checked) => { - setFreeByDefault(checked); - if (!checked) { - const newPriceId = generateUniqueId('price'); - setPrices({ [newPriceId]: { USD: '0.00', serverOnly: false } }); - } else { - setPrices({}); - } + const newPriceId = generateUniqueId('price'); + setPrices({ [newPriceId]: { USD: '0.00', serverOnly: false } }); }} /> diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/new/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/new/page-client.tsx index 064691797d..0dfe19d5c9 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/new/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/new/page-client.tsx @@ -291,7 +291,6 @@ export default function PageClient() { ? Object.keys(duplicateData.isAddOnTo as Record) : []; const duplicatePrices = duplicateData?.prices === 'include-by-default' ? {} : (duplicateData?.prices ?? {}); - const duplicateFreeByDefault = duplicateData?.prices === 'include-by-default'; // Form state - initialized from duplicate data if available const [productId, setProductId] = useState(""); @@ -303,7 +302,6 @@ export default function PageClient() { const [isAddOnTo, setIsAddOnTo] = useState(duplicateIsAddOnTo); const [stackable, setStackable] = useState(duplicateData?.stackable ?? false); const [serverOnly, setServerOnly] = useState(duplicateData?.serverOnly ?? false); - const [freeByDefault, setFreeByDefault] = useState(duplicateFreeByDefault); const [isInlineProduct, setIsInlineProduct] = useState(false); const [prices, setPrices] = useState>(duplicatePrices); const [includedItems, setIncludedItems] = useState(duplicateData?.includedItems ?? {}); @@ -372,7 +370,7 @@ export default function PageClient() { productLineId: effectiveProductLineId || undefined, isAddOnTo: isAddOn ? Object.fromEntries(isAddOnTo.map(id => [id, true])) : false, stackable, - prices: freeByDefault ? 'include-by-default' : prices, + prices, includedItems, serverOnly, freeTrial, @@ -452,8 +450,8 @@ export default function PageClient() { } } - if (!freeByDefault && Object.keys(prices).length === 0) { - newErrors.prices = "Add at least one price or enable 'Include by default'"; + if (Object.keys(prices).length === 0) { + newErrors.prices = "Add at least one price"; } return newErrors; @@ -474,7 +472,7 @@ export default function PageClient() { productLineId: effectiveProductLineId || undefined, isAddOnTo: isAddOn ? Object.fromEntries(isAddOnTo.map(id => [id, true])) : false, stackable, - prices: freeByDefault ? 'include-by-default' : prices, + prices, includedItems, serverOnly, freeTrial, @@ -537,13 +535,11 @@ export default function PageClient() { ); } - const canSave = !!(productId.trim() && displayName.trim() && (freeByDefault || Object.keys(prices).length > 0)); + const canSave = !!(productId.trim() && displayName.trim() && Object.keys(prices).length > 0); // Generate inline product code for copying const generateInlineProductCode = () => { - const pricesCode = freeByDefault - ? `'include-by-default'` - : `{ + const pricesCode = `{ ${Object.entries(prices).map(([id, price]) => { const parts = [` '${id}': { USD: '${price.USD}'`]; if (price.interval) { @@ -579,22 +575,20 @@ ${Object.entries(prices).map(([id, price]) => { // Generate prompt for creating inline product const generateInlineProductPrompt = () => { - const priceDescriptions = freeByDefault - ? 'free and included by default for all customers' - : Object.entries(prices).map(([id, price]) => { - let desc = `$${price.USD}`; - if (price.interval) { - const [count, unit] = price.interval; - desc += count === 1 ? ` per ${unit}` : ` every ${count} ${unit}s`; - } else { - desc += ' one-time'; - } - if (price.freeTrial) { - const [count, unit] = price.freeTrial; - desc += ` with ${count} ${unit}${count > 1 ? 's' : ''} free trial`; - } - return desc; - }).join(', '); + const priceDescriptions = Object.entries(prices).map(([id, price]) => { + let desc = `$${price.USD}`; + if (price.interval) { + const [count, unit] = price.interval; + desc += count === 1 ? ` per ${unit}` : ` every ${count} ${unit}s`; + } else { + desc += ' one-time'; + } + if (price.freeTrial) { + const [count, unit] = price.freeTrial; + desc += ` with ${count} ${unit}${count > 1 ? 's' : ''} free trial`; + } + return desc; + }).join(', '); const itemDescriptions = Object.entries(includedItems).map(([itemId, item]) => { const itemInfo = existingItems.find(i => i.id === itemId); @@ -792,25 +786,10 @@ ${Object.entries(prices).map(([id, price]) => { hasError={!!errors.prices} errorMessage={errors.prices} variant="form" - isFree={freeByDefault || (Object.keys(prices).length === 1 && Object.values(prices)[0].USD === '0.00')} - freeByDefault={freeByDefault} + isFree={Object.keys(prices).length === 1 && Object.values(prices)[0].USD === '0.00'} onMakeFree={() => { - setPrices({}); - setFreeByDefault(true); - }} - onMakePaid={() => { - setFreeByDefault(false); - }} - onFreeByDefaultChange={(checked) => { - setFreeByDefault(checked); - if (!checked) { - // When unchecking "included by default", set a $0 price - const newPriceId = generateUniqueId('price'); - setPrices({ [newPriceId]: { USD: '0.00', serverOnly: false } }); - } else { - // When checking "included by default", clear prices - setPrices({}); - } + const newPriceId = generateUniqueId('price'); + setPrices({ [newPriceId]: { USD: '0.00', serverOnly: false } }); }} /> diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/pricing-section.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/pricing-section.tsx index 75880ba375..00a2ccfd4d 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/pricing-section.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/pricing-section.tsx @@ -1,6 +1,6 @@ "use client"; -import { Button, Checkbox, Typography } from "@/components/ui"; +import { Button, Typography } from "@/components/ui"; import { cn } from "@/lib/utils"; import { GiftIcon, PlusIcon, TrashIcon } from "@phosphor-icons/react"; import { useState } from "react"; @@ -21,10 +21,7 @@ type PricingSectionProps = { variant?: 'form' | 'dialog', // Free product handling isFree?: boolean, - freeByDefault?: boolean, onMakeFree?: () => void, - onMakePaid?: () => void, - onFreeByDefaultChange?: (checked: boolean) => void, }; export function PricingSection({ @@ -34,10 +31,7 @@ export function PricingSection({ errorMessage, variant = 'form', isFree = false, - freeByDefault = false, onMakeFree, - onMakePaid, - onFreeByDefaultChange, }: PricingSectionProps) { const [editingPrice, setEditingPrice] = useState(null); const [isAddingPrice, setIsAddingPrice] = useState(false); @@ -165,27 +159,12 @@ export function PricingSection({ >
Free
-
- {onFreeByDefaultChange && ( - - )} -
diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/product-dialog.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/product-dialog.tsx index a59c82e458..8f6ac4a98a 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/product-dialog.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/product-dialog.tsx @@ -67,7 +67,6 @@ export function ProductDialog({ const [isAddOn, setIsAddOn] = useState(!!editingProduct?.isAddOnTo); const [isAddOnTo, setIsAddOnTo] = useState(editingProduct?.isAddOnTo !== false ? Object.keys(editingProduct?.isAddOnTo || {}) : []); const [stackable, setStackable] = useState(editingProduct?.stackable || false); - const [freeByDefault, setFreeByDefault] = useState(editingProduct?.prices === "include-by-default" || false); const [prices, setPrices] = useState>(editingProduct?.prices === "include-by-default" ? {} : editingProduct?.prices || {}); const [includedItems, setIncludedItems] = useState(editingProduct?.includedItems || {}); const [freeTrial, setFreeTrial] = useState(editingProduct?.freeTrial || undefined); @@ -168,7 +167,7 @@ export function ProductDialog({ productLineId: productLineId || undefined, isAddOnTo: isAddOn ? Object.fromEntries(isAddOnTo.map(id => [id, true])) : false, stackable, - prices: freeByDefault ? "include-by-default" : prices, + prices, includedItems, serverOnly, freeTrial, @@ -189,7 +188,6 @@ export function ProductDialog({ setIsAddOn(false); setIsAddOnTo([]); setStackable(false); - setFreeByDefault(false); setPrices({}); setIncludedItems({}); } @@ -608,38 +606,15 @@ export function ProductDialog({
- {/* Free by default */} -
- { - setFreeByDefault(checked as boolean); - if (checked) { - setPrices({}); - } - }} - /> - +
+ + +
- - This product will be automatically included for all customers at no cost - - - {/* Prices list */} - {!freeByDefault && ( -
- - - -
- )}
diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/product-price-row.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/product-price-row.tsx index 5f1ed8b21d..9f372c0c4e 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/product-price-row.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/product-price-row.tsx @@ -37,18 +37,22 @@ function LabelWithInfo({ children, tooltip }: { children: React.ReactNode, toolt type ProductPriceRowProps = { priceId: string, price: (Product['prices'] & object)[string], + /** + * Legacy display-only flag. `include-by-default` products can no longer be + * created (see the soft close in the config override route), but existing + * ones in prod configs still render with this label in view mode. + */ includeByDefault: boolean, isFree: boolean, readOnly?: boolean, startEditing?: boolean, - onSave: (newId: string | undefined, price: "include-by-default" | (Product['prices'] & object)[string]) => void, + onSave: (newId: string | undefined, price: (Product['prices'] & object)[string]) => void, onRemove?: () => void, existingPriceIds: string[], }; /** - * Displays and edits a single price for a product - * Handles both free prices (with include-by-default option) and paid prices + * Displays and edits a single price for a product. */ export function ProductPriceRow({ priceId, @@ -132,30 +136,8 @@ export function ProductPriceRow({ <>
{isFree ? ( - // Free price - show include by default option
Free -
-
- { - if (readOnly) return; - onSave(undefined, checked ? "include-by-default" : price); - }} - /> - -
-
- If enabled, customers get this product automatically when created -
-
) : ( // Paid price - show full editor diff --git a/apps/dashboard/src/components/payments/product-dialog.tsx b/apps/dashboard/src/components/payments/product-dialog.tsx index 7febbcf068..d506c9763a 100644 --- a/apps/dashboard/src/components/payments/product-dialog.tsx +++ b/apps/dashboard/src/components/payments/product-dialog.tsx @@ -5,7 +5,7 @@ import { FormDialog } from "@/components/form-dialog"; import { CheckboxField, InputField, SelectField } from "@/components/form-fields"; import { IncludedItemEditorField } from "@/components/payments/included-item-editor"; import { PriceEditorField } from "@/components/payments/price-editor"; -import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, Checkbox, FormControl, FormField, FormItem, FormLabel, FormMessage, SimpleTooltip, toast } from "@/components/ui"; +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, SimpleTooltip, toast } from "@/components/ui"; import { useUpdateConfig } from "@/lib/config-update"; import { AdminProject } from "@stackframe/stack"; import { priceOrIncludeByDefaultSchema, productSchema, userSpecifiedIdSchema, yupRecord } from "@stackframe/stack-shared/dist/schema-fields"; @@ -38,7 +38,7 @@ export function ProductDialog({ open, onOpenChange, project, mode, initial }: Pr displayName: yup.string().defined().label("Display Name"), customerType: yup.string().oneOf(["user", "team", "custom"]).defined().label("Customer Type"), prices: priceOrIncludeByDefaultSchema.defined().label("Prices").test("at-least-one-price", (value, context) => { - if (value !== "include-by-default" && Object.keys(value).length === 0) { + if (value === "include-by-default" || Object.keys(value).length === 0) { return context.createError({ message: "At least one price is required" }); } return true; @@ -99,7 +99,7 @@ export function ProductDialog({ open, onOpenChange, project, mode, initial }: Pr { value: "custom", label: "Custom" }, ]} /> - + @@ -123,28 +123,6 @@ export function ProductDialog({ open, onOpenChange, project, mode, initial }: Pr } /> - ( - - - field.onChange(checked ? "include-by-default" : {})} - /> - -
- - - Include by default - - -
- -
- )} - />
diff --git a/apps/e2e/tests/backend/endpoints/api/v1/payments/block-new-purchases.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/payments/block-new-purchases.test.ts index 87528e6a83..738ce53108 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/payments/block-new-purchases.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/payments/block-new-purchases.test.ts @@ -205,7 +205,12 @@ it("should block switch endpoint when blockNewPurchases is enabled", async ({ ex serverOnly: false, stackable: false, catalogId: "catalog", - prices: "include-by-default", + prices: { + monthly: { + USD: "1000", + interval: [1, "month"], + }, + }, includedItems: {}, }, planB: { diff --git a/apps/e2e/tests/backend/endpoints/api/v1/payments/switch-plans.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/payments/switch-plans.test.ts index aed2f93673..32e2755580 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/payments/switch-plans.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/payments/switch-plans.test.ts @@ -22,7 +22,12 @@ it("rejects switches across different product lines", async ({ expect }) => { serverOnly: false, stackable: false, productLineId: "catalogA", - prices: "include-by-default", + prices: { + monthly: { + USD: "1000", + interval: [1, "month"], + }, + }, includedItems: {}, }, planB: { @@ -63,47 +68,33 @@ it("rejects switches across different product lines", async ({ expect }) => { `); }); -it("rejects switching to include-by-default plans", async ({ expect }) => { - await setupProducts({ - planA: { - displayName: "Plan A", - customerType: "user", - serverOnly: false, - stackable: false, - productLineId: "catalog", - prices: "include-by-default", - includedItems: {}, - }, - planB: { - displayName: "Plan B", - customerType: "user", - serverOnly: false, - stackable: false, - productLineId: "catalog", - prices: "include-by-default", - includedItems: {}, - }, - }, { - catalog: { displayName: "Plans" }, - }); - - const { userId } = await Auth.fastSignUp(); - - const switchResponse = await niceBackendFetch(`/api/latest/payments/products/user/${userId}/switch`, { - method: "POST", - accessType: "client", +it("rejects creating products with the deprecated include-by-default price", async ({ expect }) => { + await Project.createAndSwitch(); + await Payments.setup(); + const response = await niceBackendFetch(`/api/latest/internal/config/override/environment`, { + accessType: "admin", + method: "PATCH", body: { - from_product_id: "planA", - to_product_id: "planB", + config_override_string: JSON.stringify({ + payments: { + productLines: { catalog: { displayName: "Plans" } }, + products: { + legacyDefault: { + displayName: "Legacy Default", + customerType: "user", + serverOnly: false, + stackable: false, + productLineId: "catalog", + prices: "include-by-default", + includedItems: {}, + }, + }, + }, + }), }, }); - expect(switchResponse).toMatchInlineSnapshot(` - NiceResponse { - "status": 400, - "body": "Include-by-default products cannot be selected for plan switching.", - "headers": Headers {