diff --git a/apps/backend/prisma/migrations/20260421000000_drop_include_by_default_snapshots/migration.sql b/apps/backend/prisma/migrations/20260421000000_drop_include_by_default_snapshots/migration.sql new file mode 100644 index 0000000000..234940898d --- /dev/null +++ b/apps/backend/prisma/migrations/20260421000000_drop_include_by_default_snapshots/migration.sql @@ -0,0 +1,34 @@ +-- Rewrite legacy `include-by-default` price sentinel in historical product JSON +-- snapshots to an empty price map, and coalesce any missing `includedItems` to +-- an empty record so downstream readers (e.g. mapProductSnapshotToInlineProduct) +-- don't throw on legacy snapshots. Include-by-default was deprecated in the +-- bulldozer payments rework and is no longer supported. +-- +-- Scale note: prod has ~5 products affected at the time of writing, so a +-- single-statement UPDATE inside Prisma's default migration transaction is fine. +-- If this ever needs to run against a larger affected row set, batch it or +-- split the migration so it runs outside a transaction. + +UPDATE "Subscription" +SET "product" = jsonb_set( + jsonb_set("product"::jsonb, '{prices}', '{}'::jsonb), + '{includedItems}', + COALESCE("product"::jsonb->'includedItems', '{}'::jsonb) +)::json +WHERE "product"->>'prices' = 'include-by-default'; + +UPDATE "OneTimePurchase" +SET "product" = jsonb_set( + jsonb_set("product"::jsonb, '{prices}', '{}'::jsonb), + '{includedItems}', + COALESCE("product"::jsonb->'includedItems', '{}'::jsonb) +)::json +WHERE "product"->>'prices' = 'include-by-default'; + +UPDATE "ProductVersion" +SET "productJson" = jsonb_set( + jsonb_set("productJson"::jsonb, '{prices}', '{}'::jsonb), + '{includedItems}', + COALESCE("productJson"::jsonb->'includedItems', '{}'::jsonb) +)::json +WHERE "productJson"->>'prices' = 'include-by-default'; diff --git a/apps/backend/scripts/verify-data-integrity/payments-verifier.ts b/apps/backend/scripts/verify-data-integrity/payments-verifier.ts index f8923bf603..e70901eed5 100644 --- a/apps/backend/scripts/verify-data-integrity/payments-verifier.ts +++ b/apps/backend/scripts/verify-data-integrity/payments-verifier.ts @@ -17,7 +17,6 @@ import { fetchAllTransactionsForProject } from "./stripe-payout-integrity"; export type CustomerType = "user" | "team" | "custom"; type PaymentsConfig = OrganizationRenderedConfig["payments"]; -type PaymentsProduct = PaymentsConfig["products"][string]; type LedgerTransaction = { amount: number, @@ -37,8 +36,6 @@ type ExpectedOwnedProduct = { quantity: number, }; -const DEFAULT_PRODUCT_START_DATE = new Date("1973-01-01T12:00:00.000Z"); - type IncludedItemConfig = { quantity?: number, repeat?: DayInterval | "never" | null, @@ -257,7 +254,6 @@ function addOneTimeIncludedItems(options: { function buildExpectedItemQuantitiesForCustomer(options: { entries: CustomerTransactionEntry[], - defaultProducts: Array<{ productId: string, product: PaymentsProduct }>, extraItemQuantityChanges: Array<{ itemId: string, quantity: number, @@ -333,20 +329,6 @@ function buildExpectedItemQuantitiesForCustomer(options: { }); } - for (const { product } of options.defaultProducts) { - addSubscriptionIncludedItems({ - ledgerByItemId, - includedItems: product.includedItems, - subscription: { - quantity: 1, - currentPeriodStart: DEFAULT_PRODUCT_START_DATE, - currentPeriodEnd: null, - createdAt: DEFAULT_PRODUCT_START_DATE, - }, - now: options.now, - }); - } - const results = new Map(); for (const [itemId, ledger] of ledgerByItemId) { results.set(itemId, computeLedgerBalanceAtNow(ledger, options.now)); @@ -356,7 +338,6 @@ function buildExpectedItemQuantitiesForCustomer(options: { function buildExpectedOwnedProductsForCustomer(options: { entries: CustomerTransactionEntry[], - defaultProducts: Array<{ productId: string, product: PaymentsProduct }>, subscriptionById: Map, oneTimePurchaseById: Map, }) { @@ -407,65 +388,9 @@ function buildExpectedOwnedProductsForCustomer(options: { }); } - for (const { productId } of options.defaultProducts) { - expected.push({ - id: productId, - type: "subscription", - quantity: 1, - }); - } - return expected; } -function getDefaultProductsForCustomer(options: { - paymentsConfig: PaymentsConfig, - customerType: CustomerType, - subscribedProductLineIds: Set, - subscribedProductIds: Set, -}) { - const defaultsByProductLine = new Map(); - const ungroupedDefaults: Array<{ productId: string, product: PaymentsProduct }> = []; - - for (const [productId, product] of Object.entries(options.paymentsConfig.products)) { - if (product.customerType !== options.customerType) continue; - if (product.prices !== "include-by-default") continue; - - if (product.productLineId) { - if (!defaultsByProductLine.has(product.productLineId)) { - defaultsByProductLine.set(product.productLineId, { productId, product }); - } - continue; - } - - ungroupedDefaults.push({ productId, product }); - } - - const defaults: Array<{ productId: string, product: PaymentsProduct }> = []; - for (const [productLineId, product] of defaultsByProductLine) { - if (options.subscribedProductLineIds.has(productLineId)) continue; - defaults.push(product); - } - for (const product of ungroupedDefaults) { - if (options.subscribedProductIds.has(product.productId)) continue; - defaults.push(product); - } - return defaults; -} - -function getIncludeByDefaultConflicts(paymentsConfig: PaymentsConfig) { - const conflicts = new Map(); - for (const productLineId of Object.keys(paymentsConfig.productLines)) { - const defaultProducts = Object.entries(paymentsConfig.products) - .filter(([_, product]) => product.productLineId === productLineId && product.prices === "include-by-default") - .map(([productId]) => productId); - if (defaultProducts.length > 1) { - conflicts.set(productLineId, defaultProducts); - } - } - return conflicts; -} - function normalizeOwnedProducts(list: ExpectedOwnedProduct[]) { // Aggregate entries by (id, type) — the bulldozer LFold sums quantities per product const merged = new Map(); @@ -530,18 +455,6 @@ export async function createPaymentsVerifier(options: { prisma: PrismaForTenancy, expectStatusCode: ExpectStatusCode, }) { - const includeByDefaultConflicts = getIncludeByDefaultConflicts(options.paymentsConfig); - if (includeByDefaultConflicts.size > 0) { - const conflictSummary = Array.from(includeByDefaultConflicts.entries()) - .map(([productLineId, productIds]) => `${productLineId}: ${productIds.join(", ")}`) - .join("; "); - console.warn(`Skipping payments verification for project ${options.projectId} due to include-by-default conflicts (${conflictSummary}).`); - return { - verifyCustomerPayments: async () => { }, - customCustomerIds: new Set(), - }; - } - const transactions = await fetchAllTransactionsForProject({ projectId: options.projectId, expectStatusCode: options.expectStatusCode, @@ -660,36 +573,8 @@ export async function createPaymentsVerifier(options: { }); const missingItemQuantityChanges = extraItemQuantityChanges.filter((change) => !entryItemQuantityChangeIds.has(change.id)); - const subscribedProductLineIds = new Set(); - const subscribedProductIds = new Set(); - const dbSubscriptions = await options.prisma.subscription.findMany({ - where: { - tenancyId: options.tenancyId, - customerId: customer.customerId, - customerType: typedToUppercase(customer.customerType), - }, - select: { - productId: true, - }, - }); - for (const { productId } of dbSubscriptions) { - if (!productId) continue; - subscribedProductIds.add(productId); - const configProduct = paymentsConfig.products[productId] as PaymentsProduct | undefined; - if (!configProduct) continue; - if (configProduct.productLineId) { - subscribedProductLineIds.add(configProduct.productLineId); - } - } - - // include-by-default products are no longer automatically granted. - // Old customers may still have them, but the bulldozer pipeline doesn't - // produce ownership for them. Skip default products in verification. - const defaultProducts: Array<{ productId: string, product: PaymentsProduct }> = []; - const expectedItems = buildExpectedItemQuantitiesForCustomer({ entries, - defaultProducts, extraItemQuantityChanges: missingItemQuantityChanges, itemQuantityChangeById, subscriptionById, @@ -732,7 +617,6 @@ export async function createPaymentsVerifier(options: { const expectedProducts = buildExpectedOwnedProductsForCustomer({ entries, - defaultProducts, subscriptionById, oneTimePurchaseById, }); 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..2740ca0044 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 @@ -168,6 +168,25 @@ const writeResponseSchema = yupObject({ bodyType: yupString().oneOf(["success"]).defined(), }); +function findIncludeByDefaultPath(value: unknown, path: string[] = []): string | null { + if (value === "include-by-default") { + // Only flag the deprecated sentinel when it sits at `payments.products.*.prices`; + // anywhere else it's just a string literal that happens to match. + if (path.length === 4 && path[0] === "payments" && path[1] === "products" && path[3] === "prices") { + return path.join("."); + } + return null; + } + if (value && typeof value === "object") { + for (const [key, child] of Object.entries(value)) { + const childPath = [...path, ...key.split(".")]; + const found = findIncludeByDefaultPath(child, childPath); + if (found) return found; + } + } + return null; +} + async function parseAndValidateConfig( configString: string, levelConfig: typeof levelConfigs["branch" | "environment" | "project"] @@ -182,6 +201,17 @@ async function parseAndValidateConfig( throw e; } + // Reject writes that use the deprecated `include-by-default` price sentinel. Reads of + // old stored configs still get migrated silently (see migrateConfigOverride) so existing + // data keeps loading, but new writes must use an explicit $0 price instead. + const legacyPath = findIncludeByDefaultPath(parsedConfig); + if (legacyPath) { + throw new StatusError( + StatusError.BadRequest, + `"include-by-default" is no longer supported at ${legacyPath}. Use an explicit $0 price instead.`, + ); + } + const migratedConfig = levelConfig.migrate(parsedConfig); const overrideError = await getConfigOverrideErrors(levelConfig.schema, migratedConfig); if (overrideError.status === "error") { diff --git a/apps/backend/src/app/api/latest/internal/payments/transactions/route.tsx b/apps/backend/src/app/api/latest/internal/payments/transactions/route.tsx index c7f6b03eb3..7f223963e4 100644 --- a/apps/backend/src/app/api/latest/internal/payments/transactions/route.tsx +++ b/apps/backend/src/app/api/latest/internal/payments/transactions/route.tsx @@ -213,21 +213,7 @@ function mapProductSnapshotToInlineProduct(product: unknown): InlineProduct { const customerType = readCustomerType(product.customerType, "product snapshot"); const includedItemsRaw = product.includedItems; - // Legacy include-by-default products may have no includedItems in their snapshot if (!isRecord(includedItemsRaw)) { - if (product.prices === "include-by-default") { - return { - display_name: typeof product.displayName === "string" ? product.displayName : "Unknown Product", - customer_type: customerType, - server_only: product.serverOnly === true, - stackable: product.stackable === true, - prices: {}, - included_items: {}, - client_metadata: isRecord(product.clientMetadata) ? product.clientMetadata : null, - client_read_only_metadata: isRecord(product.clientReadOnlyMetadata) ? product.clientReadOnlyMetadata : null, - server_metadata: isRecord(product.serverMetadata) ? product.serverMetadata : null, - }; - } throw new StackAssertionError("Invalid includedItems in product snapshot", { product }); } const includedItems: InlineProduct["included_items"] = {}; @@ -264,29 +250,27 @@ function mapProductSnapshotToInlineProduct(product: unknown): InlineProduct { } const prices: InlineProduct["prices"] = {}; - if (product.prices !== "include-by-default") { - if (!isRecord(product.prices)) { - throw new StackAssertionError("Invalid prices in product snapshot", { product }); + if (!isRecord(product.prices)) { + throw new StackAssertionError("Invalid prices in product snapshot", { product }); + } + for (const [priceId, value] of Object.entries(product.prices)) { + if (!isRecord(value)) { + throw new StackAssertionError("Invalid price config in product snapshot", { priceId, value }); } - for (const [priceId, value] of Object.entries(product.prices)) { - if (!isRecord(value)) { - throw new StackAssertionError("Invalid price config in product snapshot", { priceId, value }); - } - const mappedPrice: InlineProduct["prices"][string] = {}; - for (const currency of SUPPORTED_CURRENCIES) { - const amount = value[currency.code]; - if (typeof amount === "string") { - mappedPrice[currency.code] = amount; - } + const mappedPrice: InlineProduct["prices"][string] = {}; + for (const currency of SUPPORTED_CURRENCIES) { + const amount = value[currency.code]; + if (typeof amount === "string") { + mappedPrice[currency.code] = amount; } - if (value.interval !== undefined && value.interval !== null) { - mappedPrice.interval = readDayInterval(value.interval, `price interval for ${priceId}`); - } - if (value.freeTrial !== undefined && value.freeTrial !== null) { - mappedPrice.free_trial = readDayInterval(value.freeTrial, `price freeTrial for ${priceId}`); - } - prices[priceId] = mappedPrice; } + if (value.interval !== undefined && value.interval !== null) { + mappedPrice.interval = readDayInterval(value.interval, `price interval for ${priceId}`); + } + if (value.freeTrial !== undefined && value.freeTrial !== null) { + mappedPrice.free_trial = readDayInterval(value.freeTrial, `price freeTrial for ${priceId}`); + } + prices[priceId] = mappedPrice; } return { diff --git a/apps/backend/src/app/api/latest/internal/payments/transactions/transaction-builder.ts b/apps/backend/src/app/api/latest/internal/payments/transactions/transaction-builder.ts index eee81038b5..f68dde496d 100644 --- a/apps/backend/src/app/api/latest/internal/payments/transactions/transaction-builder.ts +++ b/apps/backend/src/app/api/latest/internal/payments/transactions/transaction-builder.ts @@ -21,7 +21,7 @@ type ProductPriceEntry = SelectedPrice & ProductPriceEntryExtras; export type ProductWithPrices = { displayName?: string, - prices?: Record | "include-by-default", + prices?: Record, } | null | undefined; type ProductSnapshot = (TransactionEntry & { type: "product_grant" })["product"]; @@ -32,7 +32,7 @@ export function resolveSelectedPriceFromProduct(product: ProductWithPrices, pric if (!product) return null; if (!priceId) return null; const prices = product.prices; - if (!prices || prices === "include-by-default") return null; + if (!prices) return null; const selected = prices[priceId as keyof typeof prices] as ProductPriceEntry | undefined; if (!selected) return null; const { serverOnly: _serverOnly, freeTrial: _freeTrial, ...rest } = selected as any; diff --git a/apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/route.ts b/apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/route.ts index c2428ab8d3..a84b95c7b5 100644 --- a/apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/route.ts +++ b/apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/route.ts @@ -79,7 +79,6 @@ export const GET = createSmartRouteHandler({ if (product.customerType !== params.customer_type) continue; if (auth.type === "client" && product.serverOnly) continue; if (!product.productLineId) continue; - if (product.prices === "include-by-default") continue; const hasIntervalPrice = typedEntries(product.prices).some(([, price]) => price.interval); if (!hasIntervalPrice) continue; if (product.isAddOnTo && typedKeys(product.isAddOnTo).length > 0) continue; diff --git a/apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/switch/route.ts b/apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/switch/route.ts index 5ea8b243d4..3989a4549a 100644 --- a/apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/switch/route.ts +++ b/apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/switch/route.ts @@ -75,16 +75,14 @@ export const POST = createSmartRouteHandler({ if (toProduct.isAddOnTo && typedKeys(toProduct.isAddOnTo).length > 0) { throw new StatusError(400, "Add-on products cannot be selected for plan switching."); } - const fromIsIncludeByDefault = fromProduct.prices === "include-by-default"; - if (toProduct.prices === "include-by-default") { - throw new StatusError(400, "Include-by-default products cannot be selected for plan switching."); - } - if (!fromIsIncludeByDefault) { - const fromHasIntervalPrice = typedEntries(fromProduct.prices as Exclude) - .some(([, price]) => price.interval); - if (!fromHasIntervalPrice) { - throw new StatusError(400, "This subscription cannot be switched."); - } + const fromPriceEntries = typedEntries(fromProduct.prices); + const fromHasIntervalPrice = fromPriceEntries.some(([, price]) => price.interval); + // A product with non-interval prices is a one-time purchase and can't be switched. + // A product with no prices at all (e.g. auto-migrated from the legacy `include-by-default` + // sentinel, or an intentionally free product) is treated as a free plan the customer may + // upgrade away from. + if (fromPriceEntries.length > 0 && !fromHasIntervalPrice) { + throw new StatusError(400, "This subscription cannot be switched."); } const prisma = await getPrismaClientForTenancy(auth.tenancy); @@ -118,7 +116,8 @@ export const POST = createSmartRouteHandler({ Object.values(subMap).filter(s => isActiveSubscription(s)).map(s => s.productId ?? "__null__") ); const hasOtpInProductLine = Object.entries(ownedProducts).some( - ([productId, p]) => p.productLineId === fromProduct.productLineId + ([productId, p]) => productId !== body.from_product_id + && p.productLineId === fromProduct.productLineId && p.quantity > 0 && !activeSubProductIds.has(productId) ); @@ -127,18 +126,36 @@ export const POST = createSmartRouteHandler({ } } - // Find the active subscription to switch from - const existingSub = !fromIsIncludeByDefault - ? Object.values(subMap).find( - s => s.productId === body.from_product_id && isActiveSubscription(s) - ) ?? null - : null; - if (!existingSub && !fromIsIncludeByDefault) { + // Find the active subscription to switch from. Customers on a free plan (no prices, or + // auto-migrated from the legacy `include-by-default` sentinel) won't have a subscription + // row — in that case we fall through to the "create a new Stripe subscription" branch. + const existingSub = Object.values(subMap).find( + s => s.productId === body.from_product_id && isActiveSubscription(s) + ) ?? null; + const fromIsFreePlan = fromPriceEntries.length === 0 + || fromPriceEntries.every(([, p]) => p.USD == null || Number(p.USD) === 0); + if (!existingSub && !fromIsFreePlan) { throw new StatusError(400, "This subscription cannot be switched."); } + // Server-granted subscriptions (no stripeSubscriptionId) are immutable via this endpoint; + // they must be cancelled through admin tooling before the customer switches plans. if (existingSub && !existingSub.stripeSubscriptionId) { throw new StatusError(400, "This subscription cannot be switched."); } + // Free-plan fallthrough: if the customer claims to be switching "from" a free product + // but actually holds a different active subscription in the same product line, reject — + // otherwise the new paid subscription would coexist with the existing one. + if (!existingSub && fromIsFreePlan && fromProduct.productLineId) { + const competingSub = Object.values(subMap).find( + s => s.productId !== body.from_product_id + && isActiveSubscription(s) + && s.productId != null + && getOrUndefined(products, s.productId)?.productLineId === fromProduct.productLineId + ); + if (competingSub) { + throw new StatusError(400, "Customer has an active subscription in this product line; switch from that product instead."); + } + } const priceEntries = typedEntries(toProduct.prices) .filter(([, price]) => price.interval); @@ -258,9 +275,8 @@ export const POST = createSmartRouteHandler({ }); await bulldozerWriteSubscription(prisma, updatedSub); } else { - // DEPRECATED: this path handles switching from include-by-default (free) products - // to paid subscriptions. Default products are being removed; this code is kept - // for backward compatibility only. + // No existing Stripe subscription — create a new one. This happens when + // switching from a $0 product (which has no stripeSubscriptionId) to a paid one. const created = await stripe.subscriptions.create({ customer: stripeCustomer.id, payment_behavior: "error_if_incomplete", diff --git a/apps/backend/src/app/api/latest/payments/purchases/validate-code/route.ts b/apps/backend/src/app/api/latest/payments/purchases/validate-code/route.ts index 999a86c6b0..1bf383bbae 100644 --- a/apps/backend/src/app/api/latest/payments/purchases/validate-code/route.ts +++ b/apps/backend/src/app/api/latest/payments/purchases/validate-code/route.ts @@ -80,7 +80,7 @@ export const POST = createSmartRouteHandler({ const productLineId = Object.keys(productLines).find((g) => product.productLineId === g); let conflictingProductLineProducts: { product_id: string, display_name: string }[] = []; if (productLineId) { - const isSubscribable = product.prices !== "include-by-default" && Object.values(product.prices).some((p: any) => p && p.interval); + const isSubscribable = Object.values(product.prices).some((p) => p.interval != null); if (isSubscribable) { const addOnBaseProductIds = product.isAddOnTo ? new Set(Object.keys(product.isAddOnTo)) : new Set(); conflictingProductLineProducts = Object.entries(ownedProducts) diff --git a/apps/backend/src/app/api/latest/teams/crud.tsx b/apps/backend/src/app/api/latest/teams/crud.tsx index 8aeda02f30..1deed30030 100644 --- a/apps/backend/src/app/api/latest/teams/crud.tsx +++ b/apps/backend/src/app/api/latest/teams/crud.tsx @@ -103,8 +103,7 @@ export const teamsCrudHandlers = createLazyProxy(() => createCrudHandlers(teamsC if (auth.project.id === "internal") { const freePlanProduct = auth.tenancy.config.payments.products.free; if (freePlanProduct.customerType === "team" && freePlanProduct.productLineId != null) { - const prices = freePlanProduct.prices === "include-by-default" ? {} : freePlanProduct.prices; - const firstPriceEntry = typedEntries(prices)[0] as [string, Record] | undefined; + const firstPriceEntry = typedEntries(freePlanProduct.prices)[0] as [string, Record] | undefined; const now = new Date(); const priceInterval = firstPriceEntry != null && "interval" in firstPriceEntry[1] ? firstPriceEntry[1].interval as [number, "day" | "week" | "month" | "year"] | undefined diff --git a/apps/backend/src/lib/payments.tsx b/apps/backend/src/lib/payments.tsx index f82a0f30a3..1b60492c68 100644 --- a/apps/backend/src/lib/payments.tsx +++ b/apps/backend/src/lib/payments.tsx @@ -21,7 +21,7 @@ import { Tenancy } from "./tenancies"; type Product = yup.InferType; type ProductWithMetadata = yup.InferType; -type SelectedPrice = Exclude[string]; +type SelectedPrice = Product["prices"][string]; export async function ensureClientCanAccessCustomer(options: { customerType: "user" | "team" | "custom", @@ -293,7 +293,7 @@ export function productToInlineProduct(product: ProductWithMetadata): yup.InferT client_metadata: product.clientMetadata ?? null, client_read_only_metadata: product.clientReadOnlyMetadata ?? null, server_metadata: product.serverMetadata ?? null, - prices: product.prices === "include-by-default" ? {} : typedFromEntries(typedEntries(product.prices).map(([key, value]) => [key, filterUndefined({ + prices: typedFromEntries(typedEntries(product.prices).map(([key, value]) => [key, filterUndefined({ ...typedFromEntries(SUPPORTED_CURRENCIES.map(c => [c.code, getOrUndefined(value, c.code)])), interval: value.interval, free_trial: value.freeTrial, @@ -317,12 +317,10 @@ export async function validatePurchaseSession(options: { const { prisma, tenancyId, customerType, customerId, product, productId, priceId, quantity } = options; // Step 1: Resolve the selected price from the product config - // (include-by-default products have no prices — kept for compatibility but not currently supported) let selectedPrice: SelectedPrice | undefined = undefined; - if (!priceId && product.prices !== "include-by-default") { + if (!priceId) { selectedPrice = typedValues(product.prices)[0]; - } - if (priceId && product.prices !== "include-by-default") { + } else { const pricesMap = new Map(typedEntries(product.prices)); selectedPrice = pricesMap.get(priceId); if (!selectedPrice) { diff --git a/apps/backend/src/lib/payments/schema/__tests__/dual-write.test.ts b/apps/backend/src/lib/payments/schema/__tests__/dual-write.test.ts index ad368991e2..6bb9c30832 100644 --- a/apps/backend/src/lib/payments/schema/__tests__/dual-write.test.ts +++ b/apps/backend/src/lib/payments/schema/__tests__/dual-write.test.ts @@ -155,7 +155,7 @@ describe("setRow via dual-write conversion", () => { customerType: "USER", productId: "prod-1", priceId: "p1", - product: { displayName: "Plan A", customerType: "user", prices: "include-by-default", includedItems: {} }, + product: { displayName: "Plan A", customerType: "user", prices: {}, includedItems: {} }, quantity: 1, stripeSubscriptionId: null, status: "active", @@ -187,7 +187,7 @@ describe("setRow via dual-write conversion", () => { customerType: "USER", productId: "prod-1", priceId: "p1", - product: { displayName: "Plan A", customerType: "user", prices: "include-by-default", includedItems: {} }, + product: { displayName: "Plan A", customerType: "user", prices: {}, includedItems: {} }, quantity: 1, stripeSubscriptionId: null, status: "active", @@ -210,7 +210,7 @@ describe("setRow via dual-write conversion", () => { customerType: "USER", productId: "prod-1", priceId: "p1", - product: { displayName: "Plan A", customerType: "user", prices: "include-by-default", includedItems: {} }, + product: { displayName: "Plan A", customerType: "user", prices: {}, includedItems: {} }, quantity: 1, stripeSubscriptionId: null, status: "canceled", diff --git a/apps/backend/src/lib/payments/schema/types.ts b/apps/backend/src/lib/payments/schema/types.ts index 2f6cd14da1..91742232cd 100644 --- a/apps/backend/src/lib/payments/schema/types.ts +++ b/apps/backend/src/lib/payments/schema/types.ts @@ -62,7 +62,7 @@ export type ProductSnapshot = { serverOnly?: boolean | null, freeTrial?: DayInterval | null, isAddOnTo?: false | Record | null, - prices: "include-by-default" | Record>, + prices: Record>, includedItems: Record, clientMetadata?: Json | null, clientReadOnlyMetadata?: Json | null, diff --git a/apps/backend/src/lib/seed-dummy-data.ts b/apps/backend/src/lib/seed-dummy-data.ts index c784d6e439..bf438443e7 100644 --- a/apps/backend/src/lib/seed-dummy-data.ts +++ b/apps/backend/src/lib/seed-dummy-data.ts @@ -330,7 +330,6 @@ const DUMMY_SEED_IDS = { designSystemsGrowth: 'a296195f-c460-4cd6-b4c4-6cd359b4c643', prototypeStarterTrial: '5a255248-4d42-4d61-95f9-f53e97c3f2dd', mateoGrowthAnnual: 'c4acea49-302a-43b9-82a7-446b19e0e662', - legacyEnterprise: '11664974-38ff-4356-8e39-2fa9105ed84f', }, itemQuantityChanges: { designSeatsGrant: '44ca1801-0732-4273-ae14-4fd1c3999e24', @@ -348,8 +347,6 @@ const DUMMY_SEED_IDS = { growthMonthly4: 'b4d5e6f7-a8b9-4012-cd3e-4f5a6b7c8d93', growthMonthly5: 'c5e6f7a8-b9c0-4123-de4f-5a6b7c8d9ea4', starterCreation: 'd6f7a8b9-c0d1-4234-ef50-6a7b8c9d0fb5', - legacyPaid1: 'e7a8b9c0-d1e2-4345-a061-7b8c9d0e1ac6', - legacyPaid2: 'f8b9c0d1-e2f3-4456-b172-8c9d0e1f2bd7', }, emails: { welcomeAmelia: 'af8cfd90-8912-4bf7-93a7-20ff2be54767', @@ -811,27 +808,6 @@ async function seedDummyTransactions(options: TransactionsSeedOptions) { stripeSubscriptionId: null, createdAt: new Date('2024-02-01T00:00:00.000Z'), }, - { - id: DUMMY_SEED_IDS.subscriptions.legacyEnterprise, - customerType: CustomerType.CUSTOM, - customerId: 'enterprise-alpha', - productId: 'legacy-enterprise', - priceId: undefined, - product: cloneJson({ - displayName: 'Legacy Enterprise Pilot', - productLineId: 'workspace', - customerType: 'user', - prices: 'include-by-default', - }), - quantity: 1, - status: SubscriptionStatus.canceled, - creationSource: PurchaseCreationSource.PURCHASE_PAGE, - currentPeriodStart: new Date('2023-11-01T00:00:00.000Z'), - currentPeriodEnd: new Date('2024-05-01T00:00:00.000Z'), - cancelAtPeriodEnd: true, - stripeSubscriptionId: 'sub_legacy_enterprise_alpha', - createdAt: new Date('2023-11-01T00:00:00.000Z'), - }, ]; for (const subscription of subscriptionSeeds) { @@ -1065,24 +1041,6 @@ async function seedDummyTransactions(options: TransactionsSeedOptions) { amountTotal: 0, createdAt: daysAgo(20, 8), }, - { - id: DUMMY_SEED_IDS.invoices.legacyPaid1, - stripeSubscriptionId: 'sub_legacy_enterprise_alpha', - stripeInvoiceId: 'in_legacy_ent_001', - isSubscriptionCreationInvoice: true, - status: 'paid', - amountTotal: 49900, - createdAt: daysAgo(28, 9), - }, - { - id: DUMMY_SEED_IDS.invoices.legacyPaid2, - stripeSubscriptionId: 'sub_legacy_enterprise_alpha', - stripeInvoiceId: 'in_legacy_ent_002', - isSubscriptionCreationInvoice: false, - status: 'paid', - amountTotal: 49900, - createdAt: daysAgo(14, 9), - }, ]; for (const invoice of invoiceSeeds) { 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..8747c6e6d3 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, @@ -30,7 +33,7 @@ import { IncludedItemDialog } from "../../included-item-dialog"; import { PricingSection } from "../../pricing-section"; import { ProductCardPreview } from "../../product-card-preview"; import { - generateUniqueId, + createFreePrice, type Price, type Product, } from "../../utils"; @@ -100,10 +103,7 @@ function EditProductForm({ productId, existingProduct }: { productId: string, ex const existingIsAddOnTo = existingIsAddOn ? Object.keys(existingProduct.isAddOnTo as Record) : []; - const existingPrices = existingProduct.prices === 'include-by-default' - ? {} - : existingProduct.prices; - const existingFreeByDefault = existingProduct.prices === 'include-by-default'; + const existingPrices = existingProduct.prices; // Form state - initialized from existing product const [displayName, setDisplayName] = useState(existingProduct.displayName || ''); @@ -112,7 +112,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 +154,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 +196,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 +218,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 +266,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 (
@@ -354,6 +353,16 @@ function EditProductForm({ productId, existingProduct }: { productId: string, ex {/* Pricing Section */}
Pricing + {Object.keys(existingPrices).length === 0 && Object.keys(prices).length === 0 && ( + + This product has no prices + + This product was previously set to "include by default", which is no longer supported. + Add an explicit $0 price below (click "Make free") to restore customer access, or + set a paid price. + + + )} { @@ -369,23 +378,9 @@ 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({}); - } + setPrices(createFreePrice()); }} />
diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/page-client.tsx index 3679d296a4..f1a4c24f5f 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/page-client.tsx @@ -44,7 +44,7 @@ import { Typography, } from "@/components/ui"; import { useUpdateConfig } from "@/lib/config-update"; -import { ArrowLeftIcon, ClockIcon, CopyIcon, CurrencyDollarIcon, DotsThreeIcon, FolderOpenIcon, GiftIcon, HardDriveIcon, PackageIcon, PencilSimpleIcon, PlusIcon, PuzzlePieceIcon, StackIcon, TagIcon, TrashIcon, UsersIcon, XIcon } from "@phosphor-icons/react"; +import { ArrowLeftIcon, ClockIcon, CopyIcon, CurrencyDollarIcon, DotsThreeIcon, FolderOpenIcon, GiftIcon, HardDriveIcon, PackageIcon, PencilSimpleIcon, PlusIcon, PuzzlePieceIcon, StackIcon, TagIcon, TrashIcon, UsersIcon } from "@phosphor-icons/react"; import type { CompleteConfig } from "@stackframe/stack-shared/dist/config/schema"; import type { Transaction, TransactionEntry } from "@stackframe/stack-shared/dist/interface/crud/transactions"; import type { DayInterval } from "@stackframe/stack-shared/dist/utils/dates"; @@ -324,6 +324,12 @@ function ProductDetailsSection({ productId, product, config }: ProductDetailsSec // Save all pending changes const handleSave = async () => { + const effectivePrices = pendingChanges.prices ?? product.prices; + if (Object.keys(effectivePrices).length === 0) { + alert("A product must have at least one price. Add a price option or make the product free before saving."); + return; + } + const configUpdate: Record = {}; // Apply product changes @@ -829,25 +835,24 @@ type ProductPricesSectionProps = { function ProductPricesSection({ productId, prices, onPricesChange, inline = false }: ProductPricesSectionProps) { const [editingPrice, setEditingPrice] = useState(null); const [isAddingPrice, setIsAddingPrice] = useState(false); + const [replacePricesOnSave, setReplacePricesOnSave] = useState(false); const handleSavePrice = (editing: EditingPrice) => { const newPrice = editingPriceToPrice(editing); - const currentPrices = prices === 'include-by-default' ? {} : prices; - const updatedPrices = { - ...currentPrices, - [editing.priceId]: newPrice, - }; + const updatedPrices = replacePricesOnSave + ? { [editing.priceId]: newPrice } + : { ...prices, [editing.priceId]: newPrice }; onPricesChange(updatedPrices); setEditingPrice(null); setIsAddingPrice(false); + setReplacePricesOnSave(false); }; const handleDeletePrice = (priceId: string) => { - const currentPrices = prices === 'include-by-default' ? {} : prices; - const { [priceId]: _, ...remainingPrices } = currentPrices as Record; - onPricesChange(Object.keys(remainingPrices).length > 0 ? remainingPrices : {}); + const { [priceId]: _, ...remainingPrices } = prices; + onPricesChange(remainingPrices); }; @@ -861,25 +866,20 @@ function ProductPricesSection({ productId, prices, onPricesChange, inline = fals setIsAddingPrice(true); }; - const isIncludeByDefault = prices === 'include-by-default'; - const priceEntries = !isIncludeByDefault ? typedEntries(prices as Record) : []; - // Check if the product has a single $0 price (free but not included by default) - const isFreeNotIncluded = priceEntries.length === 1 && priceEntries[0][1].USD === '0' || priceEntries.length === 1 && priceEntries[0][1].USD === '0.00'; - const isFree = isIncludeByDefault || isFreeNotIncluded; - const hasNoPrices = !isIncludeByDefault && priceEntries.length === 0; + const priceEntries = typedEntries(prices); + const isFree = priceEntries.length === 1 + && (priceEntries[0][1].USD === '0' || priceEntries[0][1].USD === '0.00') + && !priceEntries[0][1].interval + && !priceEntries[0][1].freeTrial + && !priceEntries[0][1].serverOnly; + const hasNoPrices = priceEntries.length === 0; const handleMakePaid = () => { - // Convert from include-by-default to empty prices object, then open add dialog - onPricesChange({}); + setReplacePricesOnSave(true); openAddDialog(); }; - const handleSetIncludeByDefault = () => { - onPricesChange('include-by-default'); - }; - - const handleSetFreeNotIncluded = () => { - // Set a $0 price to make it free but not included by default + const handleMakeFree = () => { const newPriceId = generateUniqueId('price'); onPricesChange({ [newPriceId]: { USD: '0', serverOnly: false }, @@ -889,18 +889,9 @@ function ProductPricesSection({ productId, prices, onPricesChange, inline = fals const listContent = (
{isFree ? ( - // Free product - show "Free" with option to toggle include-by-default
Free - - {isIncludeByDefault ? ( - - Included by default - - ) : ( - Not included by default - )}
- {isIncludeByDefault ? ( - - ) : ( - - )}
) : hasNoPrices ? ( @@ -954,7 +924,7 @@ function ProductPricesSection({ productId, prices, onPricesChange, inline = fals variant="ghost" size="sm" className="w-fit h-6 px-1 text-xs text-muted-foreground hover:text-foreground" - onClick={handleSetIncludeByDefault} + onClick={handleMakeFree} > Make free @@ -1018,7 +988,7 @@ function ProductPricesSection({ productId, prices, onPricesChange, inline = fals variant="ghost" size="sm" className="w-fit h-6 px-1 text-xs text-muted-foreground hover:text-foreground" - onClick={handleSetIncludeByDefault} + onClick={handleMakeFree} > Make free @@ -1036,6 +1006,7 @@ function ProductPricesSection({ productId, prices, onPricesChange, inline = fals if (!open) { setEditingPrice(null); setIsAddingPrice(false); + setReplacePricesOnSave(false); } }} editingPrice={editingPrice} 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..1dada72285 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 @@ -41,7 +41,7 @@ import { IncludedItemDialog } from "../included-item-dialog"; import { PricingSection } from "../pricing-section"; import { ProductCardPreview } from "../product-card-preview"; import { - generateUniqueId, + createFreePrice, type Price, type Product, } from "../utils"; @@ -290,8 +290,7 @@ export default function PageClient() { const duplicateIsAddOnTo = duplicateIsAddOn && duplicateData.isAddOnTo ? Object.keys(duplicateData.isAddOnTo as Record) : []; - const duplicatePrices = duplicateData?.prices === 'include-by-default' ? {} : (duplicateData?.prices ?? {}); - const duplicateFreeByDefault = duplicateData?.prices === 'include-by-default'; + const duplicatePrices = duplicateData?.prices ?? {}; // 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,9 @@ ${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({}); - } + setPrices(createFreePrice()); }} /> diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx index 2345f635ba..1337bfe4eb 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx @@ -303,9 +303,6 @@ function formatPrice(price: (Product['prices'] & object)[string]): string | null } function formatProductPrices(prices: Product['prices']): string { - if (prices === 'include-by-default') return 'Free'; - if (typeof prices !== 'object') return ''; - const formattedPrices = Object.values(prices) .map(formatPrice) .filter(Boolean) @@ -663,8 +660,6 @@ export default function PageClient() { } // If same customer type and addons, sort by lowest price const getPricePriority = (product: Product) => { - if (product.prices === 'include-by-default') return 0; - if (typeof product.prices !== 'object') return 0; return Math.min(...Object.values(product.prices).map(price => +(price.USD ?? Infinity))); }; const priceA = getPricePriority(a.product); diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-product-lines-view.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-product-lines-view.tsx index b3d7859562..1e1e47a9ea 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-product-lines-view.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-product-lines-view.tsx @@ -44,7 +44,6 @@ import { ProductDialog } from "./product-dialog"; import { ProductPriceRow } from "./product-price-row"; import { generateUniqueId, - getPricesObject, intervalLabel, shortIntervalLabel, type PricesObject, @@ -564,7 +563,7 @@ function ProductCard({ id, product, allProducts, existingItems, onSave, onDelete }; }, [hashAnchor, isHashTarget, currentHash]); - const pricesObject: PricesObject = getPricesObject(product); + const pricesObject: PricesObject = product.prices; const priceCount = Object.keys(pricesObject).length; const generateComprehensivePrompt = (): string => { @@ -592,9 +591,7 @@ function ProductCard({ id, product, allProducts, existingItems, onSave, onDelete prompt += `\n`; prompt += `## Pricing Structure\n`; - if (product.prices === 'include-by-default') { - prompt += `This product is included by default (free).\n\n`; - } else if (priceEntries.length === 0) { + if (priceEntries.length === 0) { prompt += `No prices configured.\n\n`; } else { priceEntries.forEach(([priceId, price], index) => { @@ -825,7 +822,6 @@ function ProductCard({ id, product, allProducts, existingItems, onSave, onDelete priceId={pid} price={price} isFree={false} - includeByDefault={product.prices === 'include-by-default'} readOnly={true} startEditing={false} existingPriceIds={entries.map(([k]) => k).filter(k => k !== pid)} @@ -1823,8 +1819,6 @@ export default function PageClient({ createDraftRequestId, draftCustomerType = ' } // If same customer type and addons, sort by lowest price const getPricePriority = (product: Product) => { - if (product.prices === 'include-by-default') return 0; - if (typeof product.prices !== 'object') return 0; return Math.min(...Object.values(product.prices).map(price => +(price.USD ?? Infinity))); }; const priceA = getPricePriority(a.product); 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-card-preview.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/product-card-preview.tsx index 5bf9be0785..858e013472 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/product-card-preview.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/product-card-preview.tsx @@ -7,7 +7,6 @@ import { typedEntries } from "@stackframe/stack-shared/dist/utils/objects"; import { Fragment } from "react"; import { freeTrialLabel, - getPricesObject, intervalLabel, shortIntervalLabel, type PricesObject, @@ -63,7 +62,7 @@ export function ProductCardPreview({ className, }: ProductCardPreviewProps) { const customerType = product.customerType; - const pricesObject = getPricesObject(product); + const pricesObject = product.prices; const priceEntries = typedEntries(pricesObject); const itemsList = typedEntries(product.includedItems); 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..a7eb85e83e 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,8 +67,7 @@ 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 [prices, setPrices] = useState>(editingProduct?.prices || {}); const [includedItems, setIncludedItems] = useState(editingProduct?.includedItems || {}); const [freeTrial, setFreeTrial] = useState(editingProduct?.freeTrial || undefined); const [serverOnly, setServerOnly] = useState(editingProduct?.serverOnly || false); @@ -162,13 +161,18 @@ export function ProductDialog({ }; const handleSave = async () => { + if (Object.keys(prices).length === 0) { + setErrors({ prices: "At least one price is required" }); + return; + } + const product: Product = { displayName, customerType, 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 +193,6 @@ export function ProductDialog({ setIsAddOn(false); setIsAddOnTo([]); setStackable(false); - setFreeByDefault(false); setPrices({}); setIncludedItems({}); } @@ -608,38 +611,28 @@ export function ProductDialog({
- {/* Free by default */} -
- { - setFreeByDefault(checked as boolean); - if (checked) { - setPrices({}); - } - }} - /> - +
+ + { + setPrices(newPrices); + if (errors.prices && Object.keys(newPrices).length > 0) { + setErrors(prev => { + const { prices: _, ...rest } = prev; + return rest; + }); + } + }} + variant="dialog" + /> +
- - This product will be automatically included for all customers at no cost - - - {/* Prices list */} - {!freeByDefault && ( -
- - - -
- )} + {errors.prices ? ( + + {errors.prices} + + ) : null}
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..aa24b10bf9 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 @@ -36,24 +36,21 @@ function LabelWithInfo({ children, tooltip }: { children: React.ReactNode, toolt type ProductPriceRowProps = { priceId: string, - price: (Product['prices'] & object)[string], - includeByDefault: boolean, + price: Product['prices'][string], 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'][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, price, - includeByDefault, isFree, readOnly, startEditing, @@ -132,30 +129,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 @@ -351,9 +326,6 @@ export function ProductPriceRow({ {!isFree && (
{intervalText ?? 'One-time'}
)} - {includeByDefault && ( -
Included by default
- )} {!isFree && price.freeTrial && (
diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/utils.ts b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/utils.ts index 65876639f8..ddc0d4b6a8 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/utils.ts +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/utils.ts @@ -7,8 +7,8 @@ import type { DayInterval } from "@stackframe/stack-shared/dist/utils/dates"; // ============================================================================ export type Product = CompleteConfig['payments']['products'][keyof CompleteConfig['payments']['products']]; -export type Price = (Product['prices'] & object)[string]; -export type PricesObject = Exclude; +export type Price = Product['prices'][string]; +export type PricesObject = Product['prices']; // ============================================================================ // Constants @@ -123,18 +123,10 @@ export function formatPriceDisplay(price: Price): string { } /** - * Converts prices object to array format, handling 'include-by-default' case + * Builds a fresh $0 price entry. Used as the "Make free" handler on product forms. */ -export function getPricesObject(draft: Product): PricesObject { - if (draft.prices === 'include-by-default') { - return { - "free": { - USD: '0.00', - serverOnly: false, - }, - }; - } - return draft.prices; +export function createFreePrice(): { [priceId: string]: Price } { + return { [generateUniqueId('price')]: { USD: '0.00', serverOnly: false } }; } // ============================================================================ diff --git a/apps/dashboard/src/components/data-table/transaction-table.tsx b/apps/dashboard/src/components/data-table/transaction-table.tsx index 18e502755b..71e89da2c6 100644 --- a/apps/dashboard/src/components/data-table/transaction-table.tsx +++ b/apps/dashboard/src/components/data-table/transaction-table.tsx @@ -185,8 +185,8 @@ function getUsdUnitPrice(entry: ProductGrantEntry): MoneyAmount | null { if (!entry.price_id) { return null; } - const product = entry.product as { prices?: Record | "include-by-default" } | null | undefined; - if (!product || !product.prices || product.prices === "include-by-default") { + const product = entry.product as { prices?: Record } | null | undefined; + if (!product || !product.prices) { return null; } const price = product.prices[entry.price_id]; diff --git a/apps/dashboard/src/components/payments/product-dialog.tsx b/apps/dashboard/src/components/payments/product-dialog.tsx index 7febbcf068..466f113b73 100644 --- a/apps/dashboard/src/components/payments/product-dialog.tsx +++ b/apps/dashboard/src/components/payments/product-dialog.tsx @@ -5,10 +5,10 @@ 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"; +import { pricesSchema, productSchema, userSpecifiedIdSchema, yupRecord } from "@stackframe/stack-shared/dist/schema-fields"; import { has } from "@stackframe/stack-shared/dist/utils/objects"; import * as yup from "yup"; @@ -37,8 +37,8 @@ export function ProductDialog({ open, onOpenChange, project, mode, initial }: Pr productId: userSpecifiedIdSchema("productId").defined().label("Product ID"), 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) { + prices: pricesSchema.defined().label("Prices").test("at-least-one-price", (value, context) => { + if (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..47d327bfaf 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: { @@ -228,6 +233,16 @@ it("should block switch endpoint when blockNewPurchases is enabled", async ({ ex const { userId } = await Auth.fastSignUp(); + // Grant planA ownership via server so the switch would otherwise have a valid + // source subscription — without this, the endpoint could reject for an unrelated + // reason and the test would pass by accident if the block check ever moved. + const grantResponse = await niceBackendFetch(`/api/latest/payments/products/user/${userId}`, { + method: "POST", + accessType: "server", + body: { product_id: "planA" }, + }); + expect(grantResponse.status).toBe(200); + const switchResponse = await niceBackendFetch(`/api/latest/payments/products/user/${userId}/switch`, { method: "POST", accessType: "client", @@ -251,6 +266,73 @@ it("should block switch endpoint when blockNewPurchases is enabled", async ({ ex `); }); +it("should block switch endpoint when upgrading from a $0 plan while blockNewPurchases is enabled", async ({ expect }) => { + await Project.createAndSwitch(); + await Payments.setup(); + await Project.updateConfig({ + payments: { + blockNewPurchases: true, + catalogs: { + catalog: { displayName: "Plans" }, + }, + products: { + freePlan: { + displayName: "Free", + customerType: "user", + serverOnly: false, + stackable: false, + catalogId: "catalog", + prices: { + monthly: { + USD: "0.00", + interval: [1, "month"], + }, + }, + includedItems: {}, + }, + paidPlan: { + displayName: "Paid", + customerType: "user", + serverOnly: false, + stackable: false, + catalogId: "catalog", + prices: { + monthly: { + USD: "2000", + interval: [1, "month"], + }, + }, + includedItems: {}, + }, + }, + }, + }); + + const { userId } = await Auth.fastSignUp(); + + const switchResponse = await niceBackendFetch(`/api/latest/payments/products/user/${userId}/switch`, { + method: "POST", + accessType: "client", + body: { + from_product_id: "freePlan", + to_product_id: "paidPlan", + }, + }); + expect(switchResponse).toMatchInlineSnapshot(` + NiceResponse { + "status": 403, + "body": { + "code": "NEW_PURCHASES_BLOCKED", + "error": "New purchases are currently blocked for this project. Please contact support for more information.", + }, + "headers": Headers { + "x-stack-known-error": "NEW_PURCHASES_BLOCKED", +
- {productCard("Free", "$0", null, [["10", "credits"]], false, "include-by-default", "zinc")} + {productCard("Free", "$0", null, [["10", "credits"]], false, null, null)} {productCard("Pro", "$20", "mo", [["500", "credits"], ["5", "seats"]], true, "popular", "violet")} {productCard("Enterprise", "$99", "mo", [["5,000", "credits"], ["50", "seats"]], false, null, null)}
diff --git a/packages/stack-shared/src/config/schema-fuzzer.test.ts b/packages/stack-shared/src/config/schema-fuzzer.test.ts index dde9b524db..b04e2a3c6c 100644 --- a/packages/stack-shared/src/config/schema-fuzzer.test.ts +++ b/packages/stack-shared/src/config/schema-fuzzer.test.ts @@ -124,7 +124,7 @@ const branchSchemaFuzzerConfig = [{ catalogId: ["some-product-line-id", "some-other-product-line-id"], // ensure migration works groupId: ["some-product-line-id", "some-other-product-line-id"], // ensure migration works isAddOnTo: [false, { "some-product-id": [true], "some-other-product-id": [true] }] as const, - prices: ["include-by-default" as "include-by-default", { + prices: [{ "some-price-id": [{ ...typedFromEntries(SUPPORTED_CURRENCIES.map(currency => [currency.code, ["100_00", "not a number", "Infinity", "0"]])), interval: [[[0, 1, -3, 100, 0.333, Infinity], ["day", "week", "month", "year"]]] as const, diff --git a/packages/stack-shared/src/config/schema.ts b/packages/stack-shared/src/config/schema.ts index a4d43702e5..acd800510d 100644 --- a/packages/stack-shared/src/config/schema.ts +++ b/packages/stack-shared/src/config/schema.ts @@ -452,6 +452,15 @@ export function migrateConfigOverride(type: "project" | "branch" | "environment" } // END + // BEGIN 2026-04-21: `include-by-default` product prices are no longer supported. Rewrite to an empty price map so legacy configs continue to load. + if (isBranchOrHigher) { + res = mapProperty(res, p => p.length === 4 && p[0] === "payments" && p[1] === "products" && p[3] === "prices", (value) => { + if (value === "include-by-default") return {}; + return value; + }); + } + // END + // return the result return res; }; @@ -488,6 +497,18 @@ import.meta.vitest?.test("mapProperty - basic property mapping", ({ expect }) => expect(mapProperty({ "a.b": { c: 1 } }, p => p.join(".") === "a.b.c", (value) => value + 1)).toEqual({ "a.b": { c: 2 } }); expect(mapProperty({ a: { b: { c: 1 } } }, p => p.length === 3 && p[0] === "a" && p[1] === "b", (value) => value + 1)).toEqual({ a: { b: { c: 2 } } }); + + // The include-by-default migration uses a 4-segment path predicate against dot-notation keys: + // `payments.products.X.prices`. Verify it matches whether the override is fully nested, dot-notation + // at the root, or a mix. + const sentinelToEmpty = (v: any) => v === "include-by-default" ? {} : v; + const sentinelPred = (p: string[]) => p.length === 4 && p[0] === "payments" && p[1] === "products" && p[3] === "prices"; + expect(mapProperty({ "payments.products.x.prices": "include-by-default" }, sentinelPred, sentinelToEmpty)) + .toEqual({ "payments.products.x.prices": {} }); + expect(mapProperty({ payments: { products: { x: { prices: "include-by-default" } } } }, sentinelPred, sentinelToEmpty)) + .toEqual({ payments: { products: { x: { prices: {} } } } }); + expect(mapProperty({ "payments.products": { x: { prices: "include-by-default" } } }, sentinelPred, sentinelToEmpty)) + .toEqual({ "payments.products": { x: { prices: {} } } }); }); function renameProperty(obj: Record, oldPath: string | ((path: string[]) => boolean), newName: string | ((path: string[]) => string)): any { @@ -948,12 +969,12 @@ export async function sanitizeOrganizationConfig(config: OrganizationRenderedCon const isAddOnTo = product.isAddOnTo === false ? false as const : typedFromEntries(Object.keys(product.isAddOnTo).map((key) => [key, true as const])); - const prices = product.prices === "include-by-default" ? - "include-by-default" as const : - typedFromEntries(typedEntries(product.prices).map(([key, value]) => { - const data = { serverOnly: false, ...(value ?? {}) }; - return [key, data]; - })); + type PriceEntry = Partial & { serverOnly: boolean }; + // `serverOnly` is guaranteed to be a boolean by the applyDefaults step above. + const prices: Record = typedFromEntries(typedEntries(product.prices).map(([key, value]) => { + const data: PriceEntry = { ...value }; + return [key, data]; + })); return [key, { ...product, isAddOnTo, diff --git a/packages/stack-shared/src/schema-fields.ts b/packages/stack-shared/src/schema-fields.ts index 26ffa549de..bf9702137e 100644 --- a/packages/stack-shared/src/schema-fields.ts +++ b/packages/stack-shared/src/schema-fields.ts @@ -654,12 +654,9 @@ export const productPriceSchema = yupObject({ serverOnly: yupBoolean(), freeTrial: dayIntervalSchema.optional(), }).test("at-least-one-currency", (value, context) => validateHasAtLeastOneSupportedCurrency(value, context)); -export const priceOrIncludeByDefaultSchema = yupUnion( - yupString().oneOf(['include-by-default']).meta({ openapiField: { description: 'Makes this item free and includes it by default for all customers.', exampleValue: 'include-by-default' } }), - yupRecord( - userSpecifiedIdSchema("priceId"), - productPriceSchema, - ), +export const pricesSchema = yupRecord( + userSpecifiedIdSchema("priceId"), + productPriceSchema, ); export const productSchema = yupObject({ displayName: yupString(), @@ -675,7 +672,7 @@ export const productSchema = yupObject({ freeTrial: dayIntervalSchema.optional(), serverOnly: yupBoolean(), stackable: yupBoolean(), - prices: priceOrIncludeByDefaultSchema.defined(), + prices: pricesSchema.defined(), includedItems: yupRecord( userSpecifiedIdSchema("itemId"), yupObject({