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/prisma/migrations/20260421000000_drop_include_by_default_snapshots/tests/rewrite-snapshots.ts b/apps/backend/prisma/migrations/20260421000000_drop_include_by_default_snapshots/tests/rewrite-snapshots.ts new file mode 100644 index 0000000000..327edced81 --- /dev/null +++ b/apps/backend/prisma/migrations/20260421000000_drop_include_by_default_snapshots/tests/rewrite-snapshots.ts @@ -0,0 +1,270 @@ +import { randomUUID } from 'crypto'; +import type { Sql } from 'postgres'; +import { expect } from 'vitest'; + +/** + * Migration-level test for `20260421000000_drop_include_by_default_snapshots`. + * + * The migration's job is to rewrite historical product JSON snapshots in + * three tables (`Subscription`, `OneTimePurchase`, `ProductVersion`) so that + * the legacy `"include-by-default"` price sentinel is replaced with an empty + * price record, and any missing `includedItems` field is filled in with `{}` + * (downstream readers like `mapProductSnapshotToInlineProduct` assume both + * fields exist as records). + * + * Edge cases covered: + * 1. `Subscription`: sentinel + missing `includedItems` → prices `{}`, items `{}`. + * 2. `Subscription`: sentinel + existing `includedItems` → items preserved. + * 3. `Subscription`: NO sentinel (real prices) → row untouched. + * 4. `OneTimePurchase`: sentinel → migrated identically to Subscription. + * 5. `ProductVersion`: sentinel (in `productJson` not `product`) → migrated. + * + * `tenancyId` on these tables is a UUID column without an enforced FK to + * `Tenancy`, so we can use random UUIDs without seeding the parent rows. + */ + +type Ctx = { + // Subscription IDs + subSentinelMissingItemsId: string, + subSentinelWithItemsId: string, + subRealPricesId: string, + subSentinelMissingItemsTenancy: string, + subSentinelWithItemsTenancy: string, + subRealPricesTenancy: string, + // OneTimePurchase + otpId: string, + otpTenancy: string, + // ProductVersion + pvProductVersionId: string, + pvTenancy: string, +}; + +export const preMigration = async (sql: Sql): Promise => { + const ctx: Ctx = { + subSentinelMissingItemsId: randomUUID(), + subSentinelWithItemsId: randomUUID(), + subRealPricesId: randomUUID(), + subSentinelMissingItemsTenancy: randomUUID(), + subSentinelWithItemsTenancy: randomUUID(), + subRealPricesTenancy: randomUUID(), + otpId: randomUUID(), + otpTenancy: randomUUID(), + pvProductVersionId: `pv-${randomUUID()}`, + pvTenancy: randomUUID(), + }; + + // Case 1: Subscription with sentinel + no includedItems field at all. + // `updatedAt` must be set explicitly — Prisma's `@updatedAt` annotation is + // client-side, raw SQL inserts skip it and the column is NOT NULL. + await sql` + INSERT INTO "Subscription" ( + "id", "tenancyId", "customerId", "customerType", + "productId", "priceId", "product", "quantity", + "status", "currentPeriodStart", "currentPeriodEnd", + "cancelAtPeriodEnd", "creationSource", "updatedAt" + ) VALUES ( + ${ctx.subSentinelMissingItemsId}::uuid, + ${ctx.subSentinelMissingItemsTenancy}::uuid, + 'customer-1', 'TEAM', + 'legacy-default', NULL, + ${sql.json({ + displayName: 'Legacy Default', + customerType: 'team', + prices: 'include-by-default', + })}, + 1, + 'active'::"SubscriptionStatus", + NOW(), + NOW() + interval '30 days', + false, + 'API_GRANT'::"PurchaseCreationSource", + NOW() + ) + `; + + // Case 2: Subscription with sentinel + already-populated includedItems. + // The migration must NOT overwrite this — it only fills in when missing. + await sql` + INSERT INTO "Subscription" ( + "id", "tenancyId", "customerId", "customerType", + "productId", "priceId", "product", "quantity", + "status", "currentPeriodStart", "currentPeriodEnd", + "cancelAtPeriodEnd", "creationSource", "updatedAt" + ) VALUES ( + ${ctx.subSentinelWithItemsId}::uuid, + ${ctx.subSentinelWithItemsTenancy}::uuid, + 'customer-2', 'TEAM', + 'legacy-default-2', NULL, + ${sql.json({ + displayName: 'Legacy Default With Items', + customerType: 'team', + prices: 'include-by-default', + includedItems: { + 'item-a': { quantity: 5, repeat: 'never', expires: 'never' }, + }, + })}, + 1, + 'active'::"SubscriptionStatus", + NOW(), + NOW() + interval '30 days', + false, + 'API_GRANT'::"PurchaseCreationSource", + NOW() + ) + `; + + // Case 3: Subscription with REAL prices — must remain untouched. + await sql` + INSERT INTO "Subscription" ( + "id", "tenancyId", "customerId", "customerType", + "productId", "priceId", "product", "quantity", + "status", "currentPeriodStart", "currentPeriodEnd", + "cancelAtPeriodEnd", "creationSource", "updatedAt" + ) VALUES ( + ${ctx.subRealPricesId}::uuid, + ${ctx.subRealPricesTenancy}::uuid, + 'customer-3', 'USER', + 'paid-plan', 'monthly', + ${sql.json({ + displayName: 'Paid Plan', + customerType: 'user', + prices: { + monthly: { USD: '10.00', interval: [1, 'month'], serverOnly: false }, + }, + includedItems: {}, + })}, + 1, + 'active'::"SubscriptionStatus", + NOW(), + NOW() + interval '30 days', + false, + 'PURCHASE_PAGE'::"PurchaseCreationSource", + NOW() + ) + `; + + // Case 4: OneTimePurchase with sentinel. + await sql` + INSERT INTO "OneTimePurchase" ( + "id", "tenancyId", "customerId", "customerType", + "productId", "priceId", "product", "quantity", + "creationSource" + ) VALUES ( + ${ctx.otpId}::uuid, + ${ctx.otpTenancy}::uuid, + 'customer-4', 'USER', + 'legacy-otp', NULL, + ${sql.json({ + displayName: 'Legacy OTP', + customerType: 'user', + prices: 'include-by-default', + })}, + 1, + 'API_GRANT'::"PurchaseCreationSource" + ) + `; + + // Case 5: ProductVersion with sentinel (note: column is `productJson`, not `product`). + await sql` + INSERT INTO "ProductVersion" ( + "tenancyId", "productVersionId", "productId", "productJson" + ) VALUES ( + ${ctx.pvTenancy}::uuid, + ${ctx.pvProductVersionId}, + 'legacy-pv', + ${sql.json({ + displayName: 'Legacy PV', + customerType: 'team', + prices: 'include-by-default', + })} + ) + `; + + return ctx; +}; + +export const postMigration = async (sql: Sql, ctx: Ctx) => { + // ---- Case 1 ---- + const sub1 = await sql>` + SELECT "product" FROM "Subscription" + WHERE "id" = ${ctx.subSentinelMissingItemsId}::uuid + `; + expect(sub1).toHaveLength(1); + expect(sub1[0].product).toEqual({ + displayName: 'Legacy Default', + customerType: 'team', + prices: {}, + includedItems: {}, + }); + + // ---- Case 2 ---- + const sub2 = await sql>` + SELECT "product" FROM "Subscription" + WHERE "id" = ${ctx.subSentinelWithItemsId}::uuid + `; + expect(sub2).toHaveLength(1); + expect(sub2[0].product).toEqual({ + displayName: 'Legacy Default With Items', + customerType: 'team', + prices: {}, + includedItems: { + 'item-a': { quantity: 5, repeat: 'never', expires: 'never' }, + }, + }); + + // ---- Case 3 (regression guard: don't touch real-price rows) ---- + const sub3 = await sql>` + SELECT "product" FROM "Subscription" + WHERE "id" = ${ctx.subRealPricesId}::uuid + `; + expect(sub3).toHaveLength(1); + expect(sub3[0].product).toEqual({ + displayName: 'Paid Plan', + customerType: 'user', + prices: { + monthly: { USD: '10.00', interval: [1, 'month'], serverOnly: false }, + }, + includedItems: {}, + }); + + // ---- Case 4 ---- + const otp = await sql>` + SELECT "product" FROM "OneTimePurchase" + WHERE "id" = ${ctx.otpId}::uuid + `; + expect(otp).toHaveLength(1); + expect(otp[0].product).toEqual({ + displayName: 'Legacy OTP', + customerType: 'user', + prices: {}, + includedItems: {}, + }); + + // ---- Case 5 ---- + const pv = await sql>` + SELECT "productJson" FROM "ProductVersion" + WHERE "tenancyId" = ${ctx.pvTenancy}::uuid + AND "productVersionId" = ${ctx.pvProductVersionId} + `; + expect(pv).toHaveLength(1); + expect(pv[0].productJson).toEqual({ + displayName: 'Legacy PV', + customerType: 'team', + prices: {}, + includedItems: {}, + }); + + // ---- Cross-table sanity: no row anywhere still has the sentinel ---- + const remainingSubs = await sql` + SELECT 1 FROM "Subscription" WHERE "product"->>'prices' = 'include-by-default' + `; + const remainingOtps = await sql` + SELECT 1 FROM "OneTimePurchase" WHERE "product"->>'prices' = 'include-by-default' + `; + const remainingPvs = await sql` + SELECT 1 FROM "ProductVersion" WHERE "productJson"->>'prices' = 'include-by-default' + `; + expect(remainingSubs).toHaveLength(0); + expect(remainingOtps).toHaveLength(0); + expect(remainingPvs).toHaveLength(0); +}; diff --git a/apps/backend/prisma/seed.ts b/apps/backend/prisma/seed.ts index dd86dbbf67..552b28d442 100644 --- a/apps/backend/prisma/seed.ts +++ b/apps/backend/prisma/seed.ts @@ -326,8 +326,7 @@ export async function seed() { }, }); if (!existingGrowthSub) { - const growthPrices = growthProduct.prices === 'include-by-default' ? {} : growthProduct.prices; - const firstPriceId = Object.keys(growthPrices)[0] ?? null; + const firstPriceId = Object.keys(growthProduct.prices)[0] ?? null; const now = new Date(); // Clone to ensure the stored JSON snapshot is independent of the config object // (mirrors the pattern used in seed-dummy-data.ts). 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..6207681d7a 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,34 @@ 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. The product-ID + // segment can itself contain dots (override keys are dot-paths and we split on + // ".", which fragments dotted IDs into multiple path entries), so we anchor on + // the leading `payments.products` prefix and the trailing `prices` suffix + // instead of an exact path length. + if ( + path.length >= 4 + && path[0] === "payments" + && path[1] === "products" + && path[path.length - 1] === "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 +210,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 b75dd2836a..345dca3927 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 b8f974ee80..0482ae92dd 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 (isAddOnProduct(product)) 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 ed5150a965..f015004b45 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 @@ -9,6 +9,7 @@ import { getPrismaClientForTenancy } from "@/prisma-client"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; import { KnownErrors } from "@stackframe/stack-shared"; import { adaptSchema, clientOrHigherAuthTypeSchema, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { SUPPORTED_CURRENCIES } from "@stackframe/stack-shared/dist/utils/currency-constants"; import { StackAssertionError, StatusError } from "@stackframe/stack-shared/dist/utils/errors"; import { getOrUndefined, typedEntries } from "@stackframe/stack-shared/dist/utils/objects"; import { typedToUppercase } from "@stackframe/stack-shared/dist/utils/strings"; @@ -76,16 +77,14 @@ export const POST = createSmartRouteHandler({ if (isAddOnProduct(toProduct)) { 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); @@ -119,7 +118,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) ); @@ -128,18 +128,47 @@ 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; + // A price counts as "free" only if EVERY supported currency is either absent or zero. + // Checking USD alone would misclassify a price that's only set in another supported + // currency (e.g. EUR-only) as free, and would let the customer switch from it without + // an existing subscription row — bypassing intended billing. + const isPriceFree = (price: typeof fromPriceEntries[number][1]) => + SUPPORTED_CURRENCIES.every(c => { + const amount = (price as Record)[c.code]; + return amount == null || Number(amount) === 0; + }); + const fromIsFreePlan = fromPriceEntries.length === 0 + || fromPriceEntries.every(([, p]) => isPriceFree(p)); + 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. + // (`fromProduct.productLineId` is guaranteed truthy here — the same-product-line check + // ~80 lines above already throws when it isn't.) + if (!existingSub && fromIsFreePlan) { + 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); @@ -265,9 +294,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 applicationFeePercent = getApplicationFeePercentOrUndefined(auth.tenancy.project.id); const created = await stripe.subscriptions.create({ customer: stripeCustomer.id, 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/lib/payments.tsx b/apps/backend/src/lib/payments.tsx index 5200deed91..08b1f40def 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", @@ -306,7 +306,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, @@ -330,12 +330,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/ensure-free-plan.ts b/apps/backend/src/lib/payments/ensure-free-plan.ts index 584338ee3c..a1c18e627c 100644 --- a/apps/backend/src/lib/payments/ensure-free-plan.ts +++ b/apps/backend/src/lib/payments/ensure-free-plan.ts @@ -65,8 +65,7 @@ export async function createFreePlanSubscriptionRow(options: { // First price, same as validatePurchaseSession's default when no priceId // is supplied. The `length` check is needed because TS types `[0]` as // non-undefined (no noUncheckedIndexedAccess in our tsconfig). - const prices = freePlanProduct.prices === "include-by-default" ? {} : freePlanProduct.prices; - const priceEntries = typedEntries(prices); + const priceEntries = typedEntries(freePlanProduct.prices); if (priceEntries.length === 0) { throw new StackAssertionError("Free plan has no prices configured"); } 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 e117402eb7..d9d59f2b13 100644 --- a/apps/backend/src/lib/seed-dummy-data.ts +++ b/apps/backend/src/lib/seed-dummy-data.ts @@ -345,7 +345,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', @@ -363,8 +362,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', @@ -960,27 +957,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) { @@ -1214,24 +1190,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..7e95abc562 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,8 @@ import { IncludedItemDialog } from "../../included-item-dialog"; import { PricingSection } from "../../pricing-section"; import { ProductCardPreview } from "../../product-card-preview"; import { - generateUniqueId, + createFreePrice, + isFreePrices, type Price, type Product, } from "../../utils"; @@ -100,10 +104,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 +113,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 +155,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 +197,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 +219,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 +267,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 +354,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 +379,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={isFreePrices(prices)} 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 b149011618..ba30b49332 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 @@ -6,6 +6,7 @@ import { Link, StyledLink } from "@/components/link"; import { useRouter } from "@/components/router"; import { ActionCell, + Alert, AvatarCell, Badge, Button, @@ -39,7 +40,7 @@ import { } from "@/components/ui"; import { createDefaultDataGridState, DataGrid, useDataSource, type DataGridColumnDef } from "@stackframe/dashboard-ui-components"; 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"; @@ -58,7 +59,7 @@ import { priceToEditingPrice, type EditingPrice, } from "../price-edit-dialog"; -import { DEFAULT_INTERVAL_UNITS, generateUniqueId, intervalLabel, shortIntervalLabel, type Price, type Product } from "../utils"; +import { createFreePrice, DEFAULT_INTERVAL_UNITS, generateUniqueId, intervalLabel, isFreePrices, shortIntervalLabel, type Price, type Product } from "../utils"; const CUSTOMER_TYPE_COLORS = { user: 'bg-blue-500/15 text-blue-600 dark:bg-blue-500/20 dark:text-blue-400 ring-blue-500/30', @@ -277,6 +278,10 @@ function ProductDetailsSection({ productId, product, config }: ProductDetailsSec // ===== LOCAL STATE FOR DEFERRED SAVE ===== // Track all pending changes. undefined means "use original value" const [pendingChanges, setPendingChanges] = useState({}); + // Inline validation error shown above the editable grid. We avoid `window.alert()` + // (jarring/blocking) and `toast()` (per AGENTS.md, blocking errors are easily + // missed as toasts) in favor of a destructive Alert in the design system. + const [saveValidationError, setSaveValidationError] = useState(null); // Computed local values (pending change or original) const localDisplayName = pendingChanges.displayName !== undefined ? pendingChanges.displayName : (product.displayName || ''); @@ -308,6 +313,7 @@ function ProductDetailsSection({ productId, product, config }: ProductDetailsSec // Discard all pending changes const handleDiscard = () => { setPendingChanges({}); + setSaveValidationError(null); // Reset add-on dialog state setIsAddOn(product.isAddOnTo !== false && typeof product.isAddOnTo === 'object'); setSelectedAddOnProducts( @@ -319,6 +325,13 @@ 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) { + setSaveValidationError("A product must have at least one price. Add a price option or make the product free before saving."); + return; + } + setSaveValidationError(null); + const configUpdate: Record = {}; // Apply product changes @@ -532,6 +545,10 @@ function ProductDetailsSection({ productId, product, config }: ProductDetailsSec // ===== PRICES HANDLERS (for deferred save) ===== const handlePricesChange = (newPrices: Product['prices']) => { + // Clear the "needs at least one price" error as soon as the user adds one back. + if (Object.keys(newPrices).length > 0) { + setSaveValidationError(null); + } // Deep compare to see if we're back to original const originalPrices = product.prices; if (JSON.stringify(newPrices) === JSON.stringify(originalPrices)) { @@ -725,6 +742,11 @@ function ProductDetailsSection({ productId, product, config }: ProductDetailsSec return ( <> + {saveValidationError && ( + + {saveValidationError} + + )} (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); }; @@ -856,46 +877,25 @@ 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 = isFreePrices(prices); + 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 newPriceId = generateUniqueId('price'); - onPricesChange({ - [newPriceId]: { USD: '0', serverOnly: false }, - }); + const handleMakeFree = () => { + onPricesChange(createFreePrice()); }; 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 ? ( @@ -949,7 +928,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 @@ -1013,7 +992,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 @@ -1031,6 +1010,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..884b307e82 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,8 @@ import { IncludedItemDialog } from "../included-item-dialog"; import { PricingSection } from "../pricing-section"; import { ProductCardPreview } from "../product-card-preview"; import { - generateUniqueId, + createFreePrice, + isFreePrices, type Price, type Product, } from "../utils"; @@ -290,8 +291,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 +303,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 +371,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 +451,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 +473,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 +536,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 +576,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 +787,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={isFreePrices(prices)} 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..e7f78d88fd 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,30 @@ 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 } }; +} + +/** + * Returns true if `prices` represents a "free" product: exactly one price entry + * whose USD amount is `'0'` or `'0.00'` and which has no interval, free-trial, or + * server-only flag set (any of those would change the semantics meaningfully). + * + * We accept both `'0'` and `'0.00'` for backward-compatibility with rows written + * before we standardized on `createFreePrice()` (which emits `'0.00'`). All three + * product pages (list, edit, create) call this so the "Free" indicator and the + * "Make free" / "Make paid" toggles stay in sync. + */ +export function isFreePrices(prices: PricesObject): boolean { + const entries = Object.values(prices); + if (entries.length !== 1) return false; + const [price] = entries; + return (price.USD === '0' || price.USD === '0.00') + && !price.interval + && !price.freeTrial + && !price.serverOnly; } // ============================================================================ diff --git a/apps/dashboard/src/components/data-table/transaction-table.tsx b/apps/dashboard/src/components/data-table/transaction-table.tsx index 6818d462cb..e8bcbb46af 100644 --- a/apps/dashboard/src/components/data-table/transaction-table.tsx +++ b/apps/dashboard/src/components/data-table/transaction-table.tsx @@ -184,8 +184,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..f6d0ebae54 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,10 @@ it("should block switch endpoint when blockNewPurchases is enabled", async ({ ex const { userId } = await Auth.fastSignUp(); + // Note: we don't pre-grant a source subscription here. A server-grant would create + // a row with stripeSubscriptionId=null, which the switch endpoint rejects on its own + // (route.ts ~line 143), so it wouldn't actually exercise the "would otherwise proceed" + // path. The genuine fallthrough scenario is covered by the $0-plan test below. const switchResponse = await niceBackendFetch(`/api/latest/payments/products/user/${userId}/switch`, { method: "POST", accessType: "client", @@ -251,6 +260,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/examples/demo/src/app/payments-demo/api/config-check/route.ts b/examples/demo/src/app/payments-demo/api/config-check/route.ts new file mode 100644 index 0000000000..fb24b4c2d0 --- /dev/null +++ b/examples/demo/src/app/payments-demo/api/config-check/route.ts @@ -0,0 +1,36 @@ +import { branchConfigSchema, getConfigOverrideErrors } from "@stackframe/stack-shared/dist/config/schema"; +import { ITEM_IDS, PLAN_LIMITS } from "@stackframe/stack-shared/dist/plans"; +import { NextResponse } from "next/server"; +import { stackServerApp } from "src/stack"; + +function readValidationResult(result: Awaited>) { + if (result.status === "ok") { + return { + accepted: true, + error: null, + }; + } + return { + accepted: false, + error: result.error, + }; +} + +export async function GET() { + const project = await stackServerApp.getProject(); + const includeByDefaultValidation = await getConfigOverrideErrors(branchConfigSchema, { + "payments.products.paymentsDemoInvalidFree.prices": "include-by-default", + }); + + return NextResponse.json({ + projectId: project.id, + includeByDefaultValidation: readValidationResult(includeByDefaultValidation), + expected: { + freePrice: "0.00", + freeInterval: [1, "month"], + freeEmailsPerMonth: PLAN_LIMITS.free.emailsPerMonth, + emailItemId: ITEM_IDS.emailsPerMonth, + emailsPerMonthRepeat: [1, "month"], + }, + }); +} diff --git a/examples/demo/src/app/payments-demo/api/create-checkout-url/route.ts b/examples/demo/src/app/payments-demo/api/create-checkout-url/route.ts new file mode 100644 index 0000000000..bbc49f114e --- /dev/null +++ b/examples/demo/src/app/payments-demo/api/create-checkout-url/route.ts @@ -0,0 +1,45 @@ +import { NextResponse } from "next/server"; +import { stackServerApp } from "src/stack"; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function readBody(value: unknown): { teamId: string, productId: "team" | "growth", returnUrl?: string } { + if (!isRecord(value)) { + throw new Error("Request body must be an object."); + } + + const { teamId, productId, returnUrl } = value; + if (typeof teamId !== "string" || teamId === "") { + throw new Error("teamId is required."); + } + if (productId !== "team" && productId !== "growth") { + throw new Error("productId must be team or growth."); + } + if (returnUrl !== undefined && typeof returnUrl !== "string") { + throw new Error("returnUrl must be a string."); + } + + return { teamId, productId, returnUrl }; +} + +export async function POST(request: Request) { + const user = await stackServerApp.getUser(); + if (user == null) { + return NextResponse.json({ error: "Sign in before creating a checkout URL." }, { status: 401 }); + } + const body = readBody(await request.json()); + const teams = await user.listTeams(); + const team = teams.find((candidate) => candidate.id === body.teamId); + if (team == null) { + return NextResponse.json({ error: "Current user is not a member of that team." }, { status: 403 }); + } + + const url = await team.createCheckoutUrl({ + productId: body.productId, + returnUrl: body.returnUrl, + }); + + return NextResponse.json({ url }); +} diff --git a/examples/demo/src/app/payments-demo/api/send-test-email/route.ts b/examples/demo/src/app/payments-demo/api/send-test-email/route.ts new file mode 100644 index 0000000000..fa6e8ce17c --- /dev/null +++ b/examples/demo/src/app/payments-demo/api/send-test-email/route.ts @@ -0,0 +1,40 @@ +import { NextResponse } from "next/server"; +import { stackServerApp } from "src/stack"; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function readCount(value: unknown): number { + if (!isRecord(value)) { + return 1; + } + + const count = value.count; + if (count === undefined) { + return 1; + } + if (typeof count !== "number" || !Number.isInteger(count) || count < 1 || count > 10) { + throw new Error("count must be an integer between 1 and 10."); + } + return count; +} + +export async function POST(request: Request) { + const user = await stackServerApp.getUser({ or: "throw" }); + const body: unknown = await request.json(); + const count = readCount(body); + + for (let i = 0; i < count; i++) { + await stackServerApp.sendEmail({ + userIds: [user.id], + subject: `Payments demo quota test ${i + 1}/${count}`, + html: `

Payments demo quota test email ${i + 1} of ${count}.

`, + }); + } + + return NextResponse.json({ + sent: count, + userId: user.id, + }); +} diff --git a/examples/demo/src/app/payments-demo/page.tsx b/examples/demo/src/app/payments-demo/page.tsx new file mode 100644 index 0000000000..740dcfbb8b --- /dev/null +++ b/examples/demo/src/app/payments-demo/page.tsx @@ -0,0 +1,282 @@ +"use client"; + +import { useStackApp, useUser } from "@stackframe/stack"; +import { ITEM_IDS, PLAN_LIMITS, resolvePlanId } from "@stackframe/stack-shared/dist/plans"; +import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises"; +import { Button, Card, CardContent, CardFooter, CardHeader, Input, Typography } from "@stackframe/stack-ui"; +import Link from "next/link"; +import { useMemo, useState } from "react"; + +type ActionResult = { + label: string, + detail: string, +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function stringifyJson(value: unknown): string { + return JSON.stringify(value, null, 2); +} + +function formatDate(value: Date | null): string { + if (value === null) { + return "none"; + } + return value.toLocaleString(); +} + +async function readJson(response: Response): Promise { + const value: unknown = await response.json(); + if (!response.ok) { + if (isRecord(value) && typeof value.error === "string") { + throw new Error(value.error); + } + throw new Error(`Request failed with ${response.status}`); + } + return value; +} + +async function createCheckoutUrl(options: { teamId: string, productId: "team" | "growth", returnUrl: string }): Promise { + const response = await fetch("/payments-demo/api/create-checkout-url", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(options), + }); + const data = await readJson(response); + if (!isRecord(data) || typeof data.url !== "string") { + throw new Error("Checkout route returned an invalid response."); + } + return data.url; +} + +function ProductList(props: { team: ReturnType>["useTeams"]>[number] }) { + const products = props.team.useProducts(); + const emails = props.team.useItem(ITEM_IDS.emailsPerMonth); + const activePlan = resolvePlanId(products); + + return ( + + +
+
+ {props.team.displayName} + + {props.team.id} + +
+
+
{activePlan}
+
active plan
+
+
+
+ +
+ + + +
+ +
+ + + + + + + + + + + + {products.length === 0 ? ( + + + + ) : products.map((product, index) => ( + + + + + + + + ))} + +
ProductQuantitySubscriptionPeriod endCancelable
+ No products returned yet. Wait a few seconds after team creation, then refresh. +
+
{product.displayName}
+
{product.id ?? "inline"}
+
{product.quantity} + {product.subscription === null ? "none" : ( + product.subscription.cancelAtPeriodEnd ? "canceling at period end" : "active" + )} + {product.subscription === null ? "none" : formatDate(product.subscription.currentPeriodEnd)}{product.subscription?.isCancelable ? "yes" : "no"}
+
+
+ + + + +
+ ); +} + +function Metric(props: { label: string, value: string }) { + return ( +
+
{props.value}
+
{props.label}
+
+ ); +} + +function CheckoutButton(props: { + team: ReturnType>["useTeams"]>[number], + productId: "team" | "growth", + label: string, +}) { + const [loading, setLoading] = useState(false); + + return ( + + ); +} + +export default function PaymentsDemoPage() { + const app = useStackApp(); + const project = app.useProject(); + const user = useUser({ or: "redirect" }); + const teams = user.useTeams(); + const [teamName, setTeamName] = useState(() => `Payments demo ${new Date().toISOString()}`); + const [emailCount, setEmailCount] = useState("1"); + const [result, setResult] = useState(null); + const internalDashboardUrl = useMemo(() => { + const portPrefix = process.env.NEXT_PUBLIC_STACK_PORT_PREFIX ?? "81"; + const host = portPrefix === "91" ? "a.localhost" : portPrefix === "92" ? "b.localhost" : portPrefix === "93" ? "c.localhost" : "localhost"; + return `http://${host}:${portPrefix}01/projects/internal`; + }, []); + + const createTeam = async () => { + const trimmedName = teamName.trim(); + if (trimmedName === "") { + throw new Error("Team name is required."); + } + const team = await user.createTeam({ displayName: trimmedName }); + await user.setSelectedTeam(team); + setResult({ + label: "Created team", + detail: `${team.displayName} (${team.id}). Free plan should appear after the billing grant job/webhook path catches up.`, + }); + setTeamName(`Payments demo ${new Date().toISOString()}`); + }; + + const sendTestEmails = async () => { + const count = Number(emailCount); + if (!Number.isInteger(count) || count < 1 || count > 10) { + throw new Error("Email count must be an integer between 1 and 10."); + } + const response = await fetch("/payments-demo/api/send-test-email", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ count }), + }); + const data = await readJson(response); + setResult({ + label: "Sent test emails", + detail: stringifyJson(data), + }); + }; + + const runConfigCheck = async () => { + const response = await fetch("/payments-demo/api/config-check"); + const data = await readJson(response); + setResult({ + label: "Config check", + detail: stringifyJson(data), + }); + }; + + return ( +
+
+
+
+ Payments Demo + + Manual test surface for Stack Auth internal team plans, Stripe checkout, subscription ending, and email quota deductions. + +
+ + Open internal project dashboard + +
+ + + + Test Flow + + +
+ setTeamName(e.target.value)} /> + +
+ +
+ setEmailCount(e.target.value)} inputMode="numeric" /> + + +
+ +
+
Project: {project.displayName} ({project.id})
+
Selected team: {user.selectedTeam?.displayName ?? "none"}
+
Manual Stripe end test: buy Team/Growth, end that customer subscription in Stripe Connect, wait for the webhook, then refresh here. The paid plan should disappear and Free should return.
+
+ + {result && ( +
+
{result.label}
+
{result.detail}
+
+ )} +
+
+ +
+ {teams.length === 0 ? ( + + + Create a team to start the free-plan grant test. + + + ) : teams.map((team) => ( + + ))} +
+
+
+ ); +} diff --git a/examples/demo/src/components/header.tsx b/examples/demo/src/components/header.tsx index 28742b7de9..a44f4f3cdf 100644 --- a/examples/demo/src/components/header.tsx +++ b/examples/demo/src/components/header.tsx @@ -27,6 +27,9 @@ export default function Header() { Anonymous Test + + Payments Demo +
diff --git a/packages/stack-shared/src/config/schema-fuzzer.test.ts b/packages/stack-shared/src/config/schema-fuzzer.test.ts index dde9b524db..ec6bbaf858 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, @@ -369,3 +369,9 @@ import.meta.vitest?.test("fuzz schemas", async ({ expect }) => { } } }); + +import.meta.vitest?.test("rejects include-by-default product prices in config overrides", async ({ expect }) => { + await expect(assertNoConfigOverrideErrors(branchConfigSchema, { + "payments.products.free.prices": "include-by-default", + })).rejects.toThrow(/payments\.products\.free\.prices must not be one of the following values: include-by-default/); +}); diff --git a/packages/stack-shared/src/config/schema.ts b/packages/stack-shared/src/config/schema.ts index a4d43702e5..9028e6493b 100644 --- a/packages/stack-shared/src/config/schema.ts +++ b/packages/stack-shared/src/config/schema.ts @@ -452,6 +452,19 @@ 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) { + // Match `payments.products..prices` regardless of how many segments the + // product-ID portion contributes. mapProperty splits dot-notation override keys on + // ".", so a product whose ID itself contains "." (e.g. "my.product") would produce + // a path with >4 segments and silently miss an exact-length match. + res = mapProperty(res, p => p.length >= 4 && p[0] === "payments" && p[1] === "products" && p[p.length - 1] === "prices", (value) => { + if (value === "include-by-default") return {}; + return value; + }); + } + // END + // return the result return res; }; @@ -488,6 +501,23 @@ 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 prefix/suffix path predicate against dot-notation + // keys: `payments.products..prices`, where the product-ID portion may be one or + // more segments (since override keys are dot-paths and a product ID may itself contain "."). + // Verify it matches whether the override is fully nested, dot-notation at the root, a mix, + // or contains a dotted product ID. + const sentinelToEmpty = (v: any) => v === "include-by-default" ? {} : v; + const sentinelPred = (p: string[]) => p.length >= 4 && p[0] === "payments" && p[1] === "products" && p[p.length - 1] === "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: {} } } }); + // Dotted product ID: "my.product" expands to two path segments, total 5. + expect(mapProperty({ "payments.products.my.product.prices": "include-by-default" }, sentinelPred, sentinelToEmpty)) + .toEqual({ "payments.products.my.product.prices": {} }); }); function renameProperty(obj: Record, oldPath: string | ((path: string[]) => boolean), newName: string | ((path: string[]) => string)): any { @@ -948,12 +978,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, @@ -1156,6 +1186,9 @@ export async function getConfigOverrideErrors(schema: T for (const [key, value] of Object.entries(configOverride)) { if (value === undefined) continue; + if (/^payments\.products\.[^.]+\.prices$/.test(key) && value === "include-by-default") { + return Result.error(`${key} must not be one of the following values: include-by-default`); + } const subSchema = getSubSchema(schema, key); if (!subSchema) { // find smallest key prefix that is invalid diff --git a/packages/stack-shared/src/schema-fields.ts b/packages/stack-shared/src/schema-fields.ts index 0087fe84ea..a457d4b562 100644 --- a/packages/stack-shared/src/schema-fields.ts +++ b/packages/stack-shared/src/schema-fields.ts @@ -655,12 +655,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(), @@ -676,7 +673,7 @@ export const productSchema = yupObject({ freeTrial: dayIntervalSchema.optional(), serverOnly: yupBoolean(), stackable: yupBoolean(), - prices: priceOrIncludeByDefaultSchema.defined(), + prices: pricesSchema.defined(), includedItems: yupRecord( userSpecifiedIdSchema("itemId"), yupObject({