diff --git a/.claude/CLAUDE-KNOWLEDGE.md b/.claude/CLAUDE-KNOWLEDGE.md index 6b07510097..cedbec1773 100644 --- a/.claude/CLAUDE-KNOWLEDGE.md +++ b/.claude/CLAUDE-KNOWLEDGE.md @@ -366,3 +366,6 @@ A: This happens when packages haven't been built yet. Run these commands in orde pnpm clean && pnpm i && pnpm codegen && pnpm build:packages ``` Then restart the dev server. This rebuilds all packages and generates the necessary TypeScript declarations. + +## Q: How is backwards compatibility for the offer→product rename handled in the payments purchase APIs? +A: API v1 requests are routed through the `v2beta1` migration. The migration wraps the latest handlers, accepts legacy `offer_id`/`offer_inline` request fields, translates product-related errors back to the old offer error codes/messages, and augments responses (like `validate-code`) with `offer`/`conflicting_group_offers` aliases alongside the new `product` fields. Newer API versions keep the product-only contract. diff --git a/apps/backend/prisma/migrations/20250923191615_rename_offers_to_products/migration.sql b/apps/backend/prisma/migrations/20250923191615_rename_offers_to_products/migration.sql new file mode 100644 index 0000000000..27295b9c63 --- /dev/null +++ b/apps/backend/prisma/migrations/20250923191615_rename_offers_to_products/migration.sql @@ -0,0 +1,15 @@ +-- AlterTable +ALTER TABLE "OneTimePurchase" +RENAME COLUMN "offer" TO "product"; + +-- AlterTable +ALTER TABLE "OneTimePurchase" +RENAME COLUMN "offerId" TO "productId"; + +-- AlterTable +ALTER TABLE "Subscription" +RENAME COLUMN "offer" TO "product"; + +-- AlterTable +ALTER TABLE "Subscription" +RENAME COLUMN "offerId" TO "productId"; diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 156fd07c1e..b0505448e6 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -763,9 +763,9 @@ model Subscription { tenancyId String @db.Uuid customerId String customerType CustomerType - offerId String? + productId String? priceId String? - offer Json + product Json quantity Int @default(1) stripeSubscriptionId String? @@ -802,9 +802,9 @@ model OneTimePurchase { tenancyId String @db.Uuid customerId String customerType CustomerType - offerId String? + productId String? priceId String? - offer Json + product Json quantity Int stripePaymentIntentId String? createdAt DateTime @default(now()) diff --git a/apps/backend/prisma/seed.ts b/apps/backend/prisma/seed.ts index 446dc2b462..45aea975af 100644 --- a/apps/backend/prisma/seed.ts +++ b/apps/backend/prisma/seed.ts @@ -98,14 +98,14 @@ async function seed() { } }, payments: { - groups: { + catalogs: { plans: { displayName: "Plans", } }, - offers: { + products: { team: { - groupId: "plans", + catalogId: "plans", displayName: "Team", customerType: "team", serverOnly: false, @@ -126,7 +126,7 @@ async function seed() { } }, growth: { - groupId: "plans", + catalogId: "plans", displayName: "Growth", customerType: "team", serverOnly: false, @@ -147,7 +147,7 @@ async function seed() { } }, free: { - groupId: "plans", + catalogId: "plans", displayName: "Free", customerType: "team", serverOnly: false, @@ -162,7 +162,7 @@ async function seed() { } }, "extra-admins": { - groupId: "plans", + catalogId: "plans", displayName: "Extra Admins", customerType: "team", serverOnly: false, diff --git a/apps/backend/scripts/verify-data-integrity.ts b/apps/backend/scripts/verify-data-integrity.ts index 2aeaef02a4..eda2aeea7d 100644 --- a/apps/backend/scripts/verify-data-integrity.ts +++ b/apps/backend/scripts/verify-data-integrity.ts @@ -76,6 +76,7 @@ async function main() { const shouldSaveOutput = flags.includes("--save-output"); const shouldVerifyOutput = flags.includes("--verify-output"); const shouldSkipNeon = flags.includes("--skip-neon"); + const recentFirst = flags.includes("--recent-first"); if (shouldSaveOutput) { @@ -117,7 +118,9 @@ async function main() { displayName: true, description: true, }, - orderBy: { + orderBy: recentFirst ? { + updatedAt: "desc", + } : { id: "asc", }, }); @@ -126,7 +129,7 @@ async function main() { console.log(`Starting at project ${startAt}.`); } - const maxUsersPerProject = 10000; + const maxUsersPerProject = 100; const endAt = Math.min(startAt + count, projects.length); for (let i = startAt; i < endAt; i++) { @@ -264,6 +267,7 @@ async function main() { console.log(); console.log(); } +// eslint-disable-next-line no-restricted-syntax main().catch((...args) => { console.error(); console.error(); diff --git a/apps/backend/src/app/api/latest/integrations/stripe/webhooks/route.tsx b/apps/backend/src/app/api/latest/integrations/stripe/webhooks/route.tsx index fc959604be..1fba69c1f6 100644 --- a/apps/backend/src/app/api/latest/integrations/stripe/webhooks/route.tsx +++ b/apps/backend/src/app/api/latest/integrations/stripe/webhooks/route.tsx @@ -53,7 +53,7 @@ async function processStripeWebhookEvent(event: Stripe.Event): Promise { throw new StackAssertionError("Tenancy not found", { event }); } const prisma = await getPrismaClientForTenancy(tenancy); - const offer = JSON.parse(metadata.offer || "{}"); + const product = JSON.parse(metadata.product || "{}"); const qty = Math.max(1, Number(metadata.purchaseQuantity || 1)); const stripePaymentIntentId = event.data.object.id; if (!metadata.customerId || !metadata.customerType) { @@ -73,17 +73,17 @@ async function processStripeWebhookEvent(event: Stripe.Event): Promise { tenancyId: tenancy.id, customerId: metadata.customerId, customerType: typedToUppercase(metadata.customerType), - offerId: metadata.offerId || null, + productId: metadata.productId || null, priceId: metadata.priceId || null, stripePaymentIntentId, - offer, + product, quantity: qty, creationSource: "PURCHASE_PAGE", }, update: { - offerId: metadata.offerId || null, + productId: metadata.productId || null, priceId: metadata.priceId || null, - offer, + product, quantity: qty, } }); diff --git a/apps/backend/src/app/api/latest/internal/config/override/crud.tsx b/apps/backend/src/app/api/latest/internal/config/override/crud.tsx index c16e352289..fcc1b75b6b 100644 --- a/apps/backend/src/app/api/latest/internal/config/override/crud.tsx +++ b/apps/backend/src/app/api/latest/internal/config/override/crud.tsx @@ -1,6 +1,7 @@ -import { getRenderedEnvironmentConfigQuery, overrideEnvironmentConfigOverride, validateEnvironmentConfigOverride } from "@/lib/config"; +import { getRenderedEnvironmentConfigQuery, overrideEnvironmentConfigOverride } from "@/lib/config"; import { globalPrismaClient, rawQuery } from "@/prisma-client"; import { createCrudHandlers } from "@/route-handlers/crud-handler"; +import { environmentConfigSchema, getConfigOverrideErrors, migrateConfigOverride } from "@stackframe/stack-shared/dist/config/schema"; import { configOverrideCrud } from "@stackframe/stack-shared/dist/interface/crud/config"; import { yupObject } from "@stackframe/stack-shared/dist/schema-fields"; import { StatusError } from "@stackframe/stack-shared/dist/utils/errors"; @@ -20,14 +21,10 @@ export const configOverridesCrudHandlers = createLazyProxy(() => createCrudHandl throw e; } - const validationResult = await validateEnvironmentConfigOverride({ - environmentConfigOverride: parsedConfig, - branchId: auth.tenancy.branchId, - projectId: auth.tenancy.project.id, - }); - - if (validationResult.status === "error") { - throw new StatusError(StatusError.BadRequest, validationResult.error); + // TODO instead of doing this check here, we should change overrideEnvironmentConfigOverride to return the errors from its ensureNoConfigOverrideErrors call + const overrideError = await getConfigOverrideErrors(environmentConfigSchema, migrateConfigOverride("environment", parsedConfig)); + if (overrideError.status === "error") { + throw new StatusError(StatusError.BadRequest, overrideError.error); } await overrideEnvironmentConfigOverride({ diff --git a/apps/backend/src/app/api/latest/internal/payments/test-mode-purchase-session/route.tsx b/apps/backend/src/app/api/latest/internal/payments/test-mode-purchase-session/route.tsx index aaf6653987..5144bd308f 100644 --- a/apps/backend/src/app/api/latest/internal/payments/test-mode-purchase-session/route.tsx +++ b/apps/backend/src/app/api/latest/internal/payments/test-mode-purchase-session/route.tsx @@ -37,7 +37,7 @@ export const POST = createSmartRouteHandler({ } const prisma = await getPrismaClientForTenancy(auth.tenancy); - const { selectedPrice, conflictingGroupSubscriptions } = await validatePurchaseSession({ + const { selectedPrice, conflictingCatalogSubscriptions } = await validatePurchaseSession({ prisma, tenancy: auth.tenancy, codeData: data, @@ -53,18 +53,18 @@ export const POST = createSmartRouteHandler({ data: { tenancyId: auth.tenancy.id, customerId: data.customerId, - customerType: typedToUppercase(data.offer.customerType), - offerId: data.offerId, + customerType: typedToUppercase(data.product.customerType), + productId: data.productId, priceId: price_id, - offer: data.offer, + product: data.product, quantity, creationSource: "TEST_MODE", }, }); } else { // Cancel conflicting subscriptions for TEST_MODE as well, then create new TEST_MODE subscription - if (conflictingGroupSubscriptions.length > 0) { - const conflicting = conflictingGroupSubscriptions[0]; + if (conflictingCatalogSubscriptions.length > 0) { + const conflicting = conflictingCatalogSubscriptions[0]; if (conflicting.stripeSubscriptionId) { const stripe = await getStripeForAccount({ tenancy: auth.tenancy }); await stripe.subscriptions.cancel(conflicting.stripeSubscriptionId); @@ -85,11 +85,11 @@ export const POST = createSmartRouteHandler({ data: { tenancyId: auth.tenancy.id, customerId: data.customerId, - customerType: typedToUppercase(data.offer.customerType), + customerType: typedToUppercase(data.product.customerType), status: "active", - offerId: data.offerId, + productId: data.productId, priceId: price_id, - offer: data.offer, + product: data.product, quantity, currentPeriodStart: new Date(), currentPeriodEnd: addInterval(new Date(), selectedPrice.interval!), 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 00fa49b7d7..bbf8bc71ee 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 @@ -8,15 +8,15 @@ import { typedToLowercase, typedToUppercase } from "@stackframe/stack-shared/dis type SelectedPrice = NonNullable; -type OfferWithPrices = { +type ProductWithPrices = { displayName?: string, prices?: Record | "include-by-default", } | null | undefined; -function resolveSelectedPriceFromOffer(offer: OfferWithPrices, priceId?: string | null): SelectedPrice | null { - if (!offer) return null; +function resolveSelectedPriceFromProduct(product: ProductWithPrices, priceId?: string | null): SelectedPrice | null { + if (!product) return null; if (!priceId) return null; - const prices = offer.prices; + const prices = product.prices; if (!prices || prices === "include-by-default") return null; const selected = prices[priceId as keyof typeof prices] as (SelectedPrice & { serverOnly?: unknown, freeTrial?: unknown }) | undefined; if (!selected) return null; @@ -24,8 +24,8 @@ function resolveSelectedPriceFromOffer(offer: OfferWithPrices, priceId?: string return rest as SelectedPrice; } -function getOfferDisplayName(offer: OfferWithPrices): string | null { - return offer?.displayName ?? null; +function getProductDisplayName(product: ProductWithPrices): string | null { + return product?.displayName ?? null; } @@ -137,8 +137,8 @@ export const GET = createSmartRouteHandler({ customer_id: s.customerId, quantity: s.quantity, test_mode: s.creationSource === 'TEST_MODE', - offer_display_name: getOfferDisplayName(s.offer as OfferWithPrices), - price: resolveSelectedPriceFromOffer(s.offer as OfferWithPrices, s.priceId ?? null), + product_display_name: getProductDisplayName(s.product as ProductWithPrices), + price: resolveSelectedPriceFromProduct(s.product as ProductWithPrices, s.priceId ?? null), status: s.status, })); @@ -153,7 +153,7 @@ export const GET = createSmartRouteHandler({ customer_id: i.customerId, quantity: i.quantity, test_mode: false, - offer_display_name: null, + product_display_name: null, price: null, status: null, item_id: i.itemId, @@ -170,8 +170,8 @@ export const GET = createSmartRouteHandler({ customer_id: o.customerId, quantity: o.quantity, test_mode: o.creationSource === 'TEST_MODE', - offer_display_name: getOfferDisplayName(o.offer as OfferWithPrices), - price: resolveSelectedPriceFromOffer(o.offer as OfferWithPrices, o.priceId ?? null), + product_display_name: getProductDisplayName(o.product as ProductWithPrices), + price: resolveSelectedPriceFromProduct(o.product as ProductWithPrices, o.priceId ?? null), status: null, })); diff --git a/apps/backend/src/app/api/latest/payments/purchases/create-purchase-url/route.ts b/apps/backend/src/app/api/latest/payments/purchases/create-purchase-url/route.ts index cdd4faa16b..07fef0f3ab 100644 --- a/apps/backend/src/app/api/latest/payments/purchases/create-purchase-url/route.ts +++ b/apps/backend/src/app/api/latest/payments/purchases/create-purchase-url/route.ts @@ -1,10 +1,10 @@ -import { ensureOfferIdOrInlineOffer } from "@/lib/payments"; +import { ensureProductIdOrInlineProduct } from "@/lib/payments"; import { getStripeForAccount } from "@/lib/stripe"; import { globalPrismaClient } from "@/prisma-client"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; import { CustomerType } from "@prisma/client"; import { KnownErrors } from "@stackframe/stack-shared/dist/known-errors"; -import { adaptSchema, clientOrHigherAuthTypeSchema, inlineOfferSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { adaptSchema, clientOrHigherAuthTypeSchema, inlineProductSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; import { throwErr } from "@stackframe/stack-shared/dist/utils/errors"; import { purchaseUrlVerificationCodeHandler } from "../verification-code-handler"; @@ -22,8 +22,8 @@ export const POST = createSmartRouteHandler({ body: yupObject({ customer_type: yupString().oneOf(["user", "team", "custom"]).defined(), customer_id: yupString().defined(), - offer_id: yupString().optional(), - offer_inline: inlineOfferSchema.optional(), + product_id: yupString().optional(), + product_inline: inlineProductSchema.optional(), }), }), response: yupObject({ @@ -36,10 +36,10 @@ export const POST = createSmartRouteHandler({ handler: async (req) => { const { tenancy } = req.auth; const stripe = await getStripeForAccount({ tenancy }); - const offerConfig = await ensureOfferIdOrInlineOffer(tenancy, req.auth.type, req.body.offer_id, req.body.offer_inline); - const customerType = offerConfig.customerType; + const productConfig = await ensureProductIdOrInlineProduct(tenancy, req.auth.type, req.body.product_id, req.body.product_inline); + const customerType = productConfig.customerType; if (req.body.customer_type !== customerType) { - throw new KnownErrors.OfferCustomerTypeDoesNotMatch(req.body.offer_id, req.body.customer_id, customerType, req.body.customer_type); + throw new KnownErrors.ProductCustomerTypeDoesNotMatch(req.body.product_id, req.body.customer_id, customerType, req.body.customer_type); } const stripeCustomerSearch = await stripe.customers.search({ @@ -66,8 +66,8 @@ export const POST = createSmartRouteHandler({ data: { tenancyId: tenancy.id, customerId: req.body.customer_id, - offerId: req.body.offer_id, - offer: offerConfig, + productId: req.body.product_id, + product: productConfig, stripeCustomerId: stripeCustomer.id, stripeAccountId: project?.stripeAccountId ?? throwErr("Stripe account not configured"), }, diff --git a/apps/backend/src/app/api/latest/payments/purchases/purchase-session/route.tsx b/apps/backend/src/app/api/latest/payments/purchases/purchase-session/route.tsx index 1ca9f23e4c..9f97ac6280 100644 --- a/apps/backend/src/app/api/latest/payments/purchases/purchase-session/route.tsx +++ b/apps/backend/src/app/api/latest/payments/purchases/purchase-session/route.tsx @@ -35,7 +35,7 @@ export const POST = createSmartRouteHandler({ } const stripe = await getStripeForAccount({ accountId: data.stripeAccountId }); const prisma = await getPrismaClientForTenancy(tenancy); - const { selectedPrice, conflictingGroupSubscriptions } = await validatePurchaseSession({ + const { selectedPrice, conflictingCatalogSubscriptions } = await validatePurchaseSession({ prisma, tenancy, codeData: data, @@ -46,12 +46,12 @@ export const POST = createSmartRouteHandler({ throw new StackAssertionError("Price not resolved for purchase session"); } - if (conflictingGroupSubscriptions.length > 0) { - const conflicting = conflictingGroupSubscriptions[0]; + if (conflictingCatalogSubscriptions.length > 0) { + const conflicting = conflictingCatalogSubscriptions[0]; if (conflicting.stripeSubscriptionId) { const existingStripeSub = await stripe.subscriptions.retrieve(conflicting.stripeSubscriptionId); const existingItem = existingStripeSub.items.data[0]; - const product = await stripe.products.create({ name: data.offer.displayName ?? "Subscription" }); + const product = await stripe.products.create({ name: data.product.displayName ?? "Subscription" }); if (selectedPrice.interval) { const updated = await stripe.subscriptions.update(conflicting.stripeSubscriptionId, { payment_behavior: 'default_incomplete', @@ -71,8 +71,8 @@ export const POST = createSmartRouteHandler({ quantity, }], metadata: { - offerId: data.offerId ?? null, - offer: JSON.stringify(data.offer), + productId: data.productId ?? null, + product: JSON.stringify(data.product), priceId: price_id, }, }); @@ -108,10 +108,10 @@ export const POST = createSmartRouteHandler({ customer: data.stripeCustomerId, automatic_payment_methods: { enabled: true }, metadata: { - offerId: data.offerId || "", - offer: JSON.stringify(data.offer), + productId: data.productId || "", + product: JSON.stringify(data.product), customerId: data.customerId, - customerType: data.offer.customerType, + customerType: data.product.customerType, purchaseQuantity: String(quantity), purchaseKind: "ONE_TIME", tenancyId: data.tenancyId, @@ -127,7 +127,7 @@ export const POST = createSmartRouteHandler({ } const product = await stripe.products.create({ - name: data.offer.displayName ?? "Subscription", + name: data.product.displayName ?? "Subscription", }); const created = await stripe.subscriptions.create({ customer: data.stripeCustomerId, @@ -147,8 +147,8 @@ export const POST = createSmartRouteHandler({ quantity, }], metadata: { - offerId: data.offerId ?? null, - offer: JSON.stringify(data.offer), + productId: data.productId ?? null, + product: JSON.stringify(data.product), priceId: price_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 1807b101f7..12fda88c56 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 @@ -2,14 +2,14 @@ import { getSubscriptions, isActiveSubscription } from "@/lib/payments"; import { getTenancy } from "@/lib/tenancies"; import { getPrismaClientForTenancy } from "@/prisma-client"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; -import { inlineOfferSchema, yupArray, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { inlineProductSchema, yupArray, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; import { SUPPORTED_CURRENCIES } from "@stackframe/stack-shared/dist/utils/currency-constants"; import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; import { filterUndefined, getOrUndefined, typedEntries, typedFromEntries } from "@stackframe/stack-shared/dist/utils/objects"; import * as yup from "yup"; import { purchaseUrlVerificationCodeHandler } from "../verification-code-handler"; -const offerDataSchema = inlineOfferSchema +const productDataSchema = inlineProductSchema .omit(["server_only", "included_items"]) .concat(yupObject({ stackable: yupBoolean().defined(), @@ -28,12 +28,12 @@ export const POST = createSmartRouteHandler({ statusCode: yupNumber().oneOf([200]).defined(), bodyType: yupString().oneOf(["json"]).defined(), body: yupObject({ - offer: offerDataSchema, + product: productDataSchema, stripe_account_id: yupString().defined(), project_id: yupString().defined(), already_bought_non_stackable: yupBoolean().defined(), - conflicting_group_offers: yupArray(yupObject({ - offer_id: yupString().defined(), + conflicting_products: yupArray(yupObject({ + product_id: yupString().defined(), display_name: yupString().defined(), }).defined()).defined(), }).defined(), @@ -44,12 +44,12 @@ export const POST = createSmartRouteHandler({ if (!tenancy) { throw new StackAssertionError(`No tenancy found for given tenancyId`); } - const offer = verificationCode.data.offer; - const offerData: yup.InferType = { - display_name: offer.displayName ?? "Offer", - customer_type: offer.customerType, - stackable: offer.stackable === true, - prices: offer.prices === "include-by-default" ? {} : typedFromEntries(typedEntries(offer.prices).map(([key, value]) => [key, filterUndefined({ + const product = verificationCode.data.product; + const productData: yup.InferType = { + display_name: product.displayName ?? "Product", + customer_type: product.customerType, + stackable: product.stackable === true, + prices: product.prices === "include-by-default" ? {} : 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, @@ -61,28 +61,28 @@ export const POST = createSmartRouteHandler({ const subscriptions = await getSubscriptions({ prisma, tenancy, - customerType: offer.customerType, + customerType: product.customerType, customerId: verificationCode.data.customerId, }); - const alreadyBoughtNonStackable = !!(subscriptions.find((s) => s.offerId === verificationCode.data.offerId) && offer.stackable !== true); + const alreadyBoughtNonStackable = !!(subscriptions.find((s) => s.productId === verificationCode.data.productId) && product.stackable !== true); - const groups = tenancy.config.payments.groups; - const groupId = Object.keys(groups).find((g) => offer.groupId === g); - let conflictingGroupOffers: { offer_id: string, display_name: string }[] = []; - if (groupId) { - const isSubscribable = offer.prices !== "include-by-default" && Object.values(offer.prices).some((p: any) => p && p.interval); + const catalogs = tenancy.config.payments.catalogs; + const catalogId = Object.keys(catalogs).find((g) => product.catalogId === g); + let conflictingCatalogProducts: { product_id: string, display_name: string }[] = []; + if (catalogId) { + const isSubscribable = product.prices !== "include-by-default" && Object.values(product.prices).some((p: any) => p && p.interval); if (isSubscribable) { const conflicts = subscriptions.filter((subscription) => ( - subscription.offerId && - subscription.offer.groupId === groupId && + subscription.productId && + subscription.product.catalogId === catalogId && isActiveSubscription(subscription) && - subscription.offer.prices !== "include-by-default" && - (!offer.isAddOnTo || !Object.keys(offer.isAddOnTo).includes(subscription.offerId)) + subscription.product.prices !== "include-by-default" && + (!product.isAddOnTo || !Object.keys(product.isAddOnTo).includes(subscription.productId)) )); - conflictingGroupOffers = conflicts.map((s) => ({ - offer_id: s.offerId!, - display_name: s.offer.displayName ?? s.offerId!, + conflictingCatalogProducts = conflicts.map((s) => ({ + product_id: s.productId!, + display_name: s.product.displayName ?? s.productId!, })); } } @@ -91,11 +91,11 @@ export const POST = createSmartRouteHandler({ statusCode: 200, bodyType: "json", body: { - offer: offerData, + product: productData, stripe_account_id: verificationCode.data.stripeAccountId, project_id: tenancy.project.id, already_bought_non_stackable: alreadyBoughtNonStackable, - conflicting_group_offers: conflictingGroupOffers, + conflicting_products: conflictingCatalogProducts, }, }; }, diff --git a/apps/backend/src/app/api/latest/payments/purchases/verification-code-handler.tsx b/apps/backend/src/app/api/latest/payments/purchases/verification-code-handler.tsx index f20e9f684b..1339821c13 100644 --- a/apps/backend/src/app/api/latest/payments/purchases/verification-code-handler.tsx +++ b/apps/backend/src/app/api/latest/payments/purchases/verification-code-handler.tsx @@ -1,6 +1,6 @@ import { createVerificationCodeHandler } from "@/route-handlers/verification-code-handler"; import { VerificationCodeType } from "@prisma/client"; -import { offerSchema, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { productSchema, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; export const purchaseUrlVerificationCodeHandler = createVerificationCodeHandler({ type: VerificationCodeType.PURCHASE_URL, @@ -8,8 +8,8 @@ export const purchaseUrlVerificationCodeHandler = createVerificationCodeHandler( data: yupObject({ tenancyId: yupString().defined(), customerId: yupString().defined(), - offerId: yupString(), - offer: offerSchema, + productId: yupString(), + product: productSchema, stripeCustomerId: yupString().defined(), stripeAccountId: yupString().defined(), }), diff --git a/apps/backend/src/app/api/migrations/README.md b/apps/backend/src/app/api/migrations/README.md index ce2772d7d3..7f0314eda8 100644 --- a/apps/backend/src/app/api/migrations/README.md +++ b/apps/backend/src/app/api/migrations/README.md @@ -13,7 +13,7 @@ Examples of changes that are breaking and hence require an API migration: Release versions (eg. v1, v2) are documented thoroughly, while beta versions (eg. v2beta1, v3beta2) are mostly used by our own packages/SDKs and some external beta testers. **We still need to maintain backwards compatibility for beta versions**, so the only purpose of differentiating is to prevent "migration fatigue" if we were to announce a new API version every week. Beta versions come before release versions: `v1 < v2beta1 < v2beta2 < v2`, etc. -Each folder in `src/app/api/migrations` is a migration. The name of the folder is the name of the version you're migrating **to** — so, if you're migrating from `v2beta3` to `v2beta4`, the folder is called `v2beta4`. (Make sure you don't get confused because it means the file `migrations/v2beta4/route.tsx` is the migration file TO `v2beta4`, hence never served to clients in `v2beta4`.) +Each folder in `src/app/api/migrations` is a migration. The name of the folder is the name of the version you're migrating **to** — so, if you're migrating from `v2beta3` to `v2beta4`, the folder is called `v2beta4`. In other words, the files in `v2beta4` will process all requests for versions LESS than `v2beta4` (but not `v2beta4` itself). To create a new migration, simply add a new folder in `src/app/api/migrations`. This folder has the same structure as `src/app/api/latest`, although it will fall back to that folder for routes that are not found. Additionally, this new folder should contain extra files: `beta-changes.txt` (the list of changes since the last beta version), and `release-changes.txt` (the list of changes since the last release version — only required for release versions). For every endpoint you migrate, you will likely also have to modify the most recent migration of that endpoint in previous versions (if any) to call your newly created endpoint, instead of the one that can be found in `latest`. diff --git a/apps/backend/src/app/api/migrations/v2beta5/beta-changes.txt b/apps/backend/src/app/api/migrations/v2beta5/beta-changes.txt new file mode 100644 index 0000000000..d941bdb148 --- /dev/null +++ b/apps/backend/src/app/api/migrations/v2beta5/beta-changes.txt @@ -0,0 +1 @@ +Removes the offer_id and offer_inline parameters that previously acted as aliases for product_id and product_inline on some endpoints. diff --git a/apps/backend/src/app/api/migrations/v2beta5/payments/purchases/create-purchase-url/route.ts b/apps/backend/src/app/api/migrations/v2beta5/payments/purchases/create-purchase-url/route.ts new file mode 100644 index 0000000000..686e21d854 --- /dev/null +++ b/apps/backend/src/app/api/migrations/v2beta5/payments/purchases/create-purchase-url/route.ts @@ -0,0 +1,28 @@ +import { POST as latestHandler } from "@/app/api/latest/payments/purchases/create-purchase-url/route"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { ensureObjectSchema, inlineProductSchema, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { normalizePurchaseBody } from "../offers-compat"; + +const latestInit = latestHandler.initArgs[0]; + +const requestSchema = ensureObjectSchema(latestInit.request); +const requestBodySchema = ensureObjectSchema(requestSchema.getNested("body")); + +export const POST = createSmartRouteHandler({ + ...latestInit, + request: requestSchema.concat(yupObject({ + body: requestBodySchema.concat(yupObject({ + offer_id: yupString().optional(), + offer_inline: inlineProductSchema.optional(), + })), + })), + handler: async (_req, fullReq) => { + const body = normalizePurchaseBody(fullReq.body as Record); + const translatedRequest = { + ...fullReq, + body, + }; + + return await latestHandler.invoke(translatedRequest); + }, +}); diff --git a/apps/backend/src/app/api/migrations/v2beta5/payments/purchases/offers-compat.ts b/apps/backend/src/app/api/migrations/v2beta5/payments/purchases/offers-compat.ts new file mode 100644 index 0000000000..ba54fd3fe8 --- /dev/null +++ b/apps/backend/src/app/api/migrations/v2beta5/payments/purchases/offers-compat.ts @@ -0,0 +1,23 @@ + + +export function normalizePurchaseBody(body: Record): Record { + const productId = body.product_id ?? body.offer_id; + const productInline = body.product_inline ?? body.offer_inline; + const result: Record = { ...body, product_id: productId, product_inline: productInline }; + delete result.offer_id; + delete result.offer_inline; + return result; +} + + +import.meta.vitest?.test("normalizePurchaseBody maps offer fields to product equivalents", ({ expect }) => { + const legacyBody = { offer_id: "legacy_offer", offer_inline: { foo: "bar" } } as Record; + + const normalized = normalizePurchaseBody(legacyBody); + + expect(normalized.product_id).toBe("legacy_offer"); + expect(normalized.product_inline).toBe(legacyBody.offer_inline); + expect(normalized).not.toHaveProperty("offer_id"); + expect(normalized).not.toHaveProperty("offer_inline"); + expect(legacyBody).toEqual({ offer_id: "legacy_offer", offer_inline: { foo: "bar" } }); +}); diff --git a/apps/backend/src/lib/config.tsx b/apps/backend/src/lib/config.tsx index 93684e6af4..03a22468af 100644 --- a/apps/backend/src/lib/config.tsx +++ b/apps/backend/src/lib/config.tsx @@ -205,10 +205,11 @@ export async function overrideProjectConfigOverride(options: { // TODO put this in a serializable transaction (or a single SQL query) to prevent race conditions const oldConfig = await rawQuery(globalPrismaClient, getProjectConfigOverrideQuery(options)); - const newConfig = override( + const newConfigUnmigrated = override( oldConfig, options.projectConfigOverrideOverride, ); + const newConfig = migrateConfigOverride("project", newConfigUnmigrated); // large configs make our DB slow; let's prevent them early const newConfigString = JSON.stringify(newConfig); @@ -249,10 +250,11 @@ export async function overrideEnvironmentConfigOverride(options: { // TODO put this in a serializable transaction (or a single SQL query) to prevent race conditions const oldConfig = await rawQuery(globalPrismaClient, getEnvironmentConfigOverrideQuery(options)); - const newConfig = override( + const newConfigUnmigrated = override( oldConfig, options.environmentConfigOverrideOverride, ); + const newConfig = migrateConfigOverride("environment", newConfigUnmigrated); // large configs make our DB slow; let's prevent them early const newConfigString = JSON.stringify(newConfig); @@ -434,8 +436,10 @@ import.meta.vitest?.test('_validateConfigOverrideSchemaImpl(...)', async ({ expe expect(await validateConfigOverrideSchema(yupObject({ a: yupObject({ b: yupObject({ c: yupString().defined() }).defined() }).defined() }), { a: { b: {} } }, { "a.b": { c: 123 } })).toEqual(Result.error("[ERROR] a.b.c must be a `string` type, but the final value was: `123`.")); expect(await validateConfigOverrideSchema(yupObject({ a: yupString().defined().oneOf(['b']) }), {}, { a: 'c' })).toEqual(Result.error("[ERROR] a must be one of the following values: b")); expect(await validateConfigOverrideSchema(yupObject({ a: yupString().defined() }), {}, {})).toEqual(Result.error("[WARNING] a must be defined")); - expect(await validateConfigOverrideSchema(yupObject({ a: yupMixed() }), {}, { "a.b": "c" })).toEqual(Result.error(`[ERROR] The key \"a.b\" is not valid for the schema.`)); - expect(await validateConfigOverrideSchema(yupObject({ a: yupMixed() }), { a: 'str' }, { "a.b": "c" })).toEqual(Result.error(`[ERROR] The key \"a.b\" is not valid for the schema.`)); + expect(await validateConfigOverrideSchema(yupObject({}), {}, { "a.b": "c" })).toEqual(Result.error(`[ERROR] The key \"a.b\" is not valid (nested object not found in schema: "a").`)); + expect(await validateConfigOverrideSchema(yupObject({ a: yupMixed() }), {}, { "a.b": "c" })).toEqual(Result.error(`[ERROR] The key \"a.b\" is not valid (nested object not found in schema: "a.b").`)); + expect(await validateConfigOverrideSchema(yupObject({ a: yupMixed() }), { a: 'str' }, { "a.b": "c" })).toEqual(Result.error(`[ERROR] The key \"a.b\" is not valid (nested object not found in schema: "a.b").`)); + expect(await validateConfigOverrideSchema(yupObject({ a: yupObject({ c: yupString().optional() }) }), { a: 'str' }, { "a.b": "c" })).toEqual(Result.error(`[ERROR] The key \"a.b\" is not valid (nested object not found in schema: "a.b").`)); expect(await validateConfigOverrideSchema(schema1, {}, { a: 123 })).toEqual(Result.error('[ERROR] a must be a `string` type, but the final value was: `123`.')); expect(await validateConfigOverrideSchema(unionSchema, { a: { "time": "now" } }, { "a.morning": true })).toMatchInlineSnapshot(` { diff --git a/apps/backend/src/lib/payments.test.tsx b/apps/backend/src/lib/payments.test.tsx index 7db9cb30aa..0f9145b470 100644 --- a/apps/backend/src/lib/payments.test.tsx +++ b/apps/backend/src/lib/payments.test.tsx @@ -56,8 +56,8 @@ describe('getItemQuantityForCustomer - manual changes (no subscription)', () => customerType: 'custom', }, }, - offers: {}, - groups: {}, + products: {}, + catalogs: {}, }); const prisma = createMockPrisma({ @@ -100,8 +100,8 @@ describe('getItemQuantityForCustomer - manual changes (no subscription)', () => customerType: 'custom', }, }, - offers: {}, - groups: {}, + products: {}, + catalogs: {}, }); const prisma = createMockPrisma({ @@ -146,10 +146,10 @@ describe('getItemQuantityForCustomer - subscriptions', () => { items: { [itemId]: { displayName: 'S', customerType: 'user' }, }, - groups: { g1: { displayName: 'G' } }, - offers: { + catalogs: { g1: { displayName: 'G' } }, + products: { off1: { - displayName: 'O', groupId: 'g1', customerType: 'user', freeTrial: undefined, serverOnly: false, stackable: false, + displayName: 'O', catalogId: 'g1', customerType: 'user', freeTrial: undefined, serverOnly: false, stackable: false, prices: {}, includedItems: { [itemId]: { quantity: 3, repeat: 'never', expires: 'when-purchase-expires' } }, isAddOnTo: false, @@ -160,7 +160,7 @@ describe('getItemQuantityForCustomer - subscriptions', () => { const prisma = createMockPrisma({ subscription: { findMany: async () => [{ - offerId: 'off1', + productId: 'off1', currentPeriodStart: new Date('2025-02-01T00:00:00.000Z'), currentPeriodEnd: new Date('2025-02-28T23:59:59.000Z'), quantity: 2, @@ -184,10 +184,10 @@ describe('getItemQuantityForCustomer - subscriptions', () => { items: { [itemId]: { displayName: 'S', customerType: 'user' }, }, - groups: { g1: { displayName: 'G' } }, - offers: { + catalogs: { g1: { displayName: 'G' } }, + products: { offW: { - displayName: 'O', groupId: 'g1', customerType: 'user', freeTrial: undefined, serverOnly: false, stackable: false, + displayName: 'O', catalogId: 'g1', customerType: 'user', freeTrial: undefined, serverOnly: false, stackable: false, prices: {}, includedItems: { [itemId]: { quantity: 4, repeat: [1, 'week'], expires: 'when-purchase-expires' } }, isAddOnTo: false, @@ -198,7 +198,7 @@ describe('getItemQuantityForCustomer - subscriptions', () => { const prisma = createMockPrisma({ subscription: { findMany: async () => [{ - offerId: 'offW', + productId: 'offW', currentPeriodStart: new Date('2025-02-01T00:00:00.000Z'), currentPeriodEnd: new Date('2025-03-01T00:00:00.000Z'), quantity: 1, @@ -223,10 +223,10 @@ describe('getItemQuantityForCustomer - subscriptions', () => { items: { [itemId]: { displayName: 'S', customerType: 'user' }, }, - groups: { g1: { displayName: 'G' } }, - offers: { + catalogs: { g1: { displayName: 'G' } }, + products: { offW: { - displayName: 'O', groupId: 'g1', customerType: 'user', freeTrial: undefined, serverOnly: false, stackable: false, + displayName: 'O', catalogId: 'g1', customerType: 'user', freeTrial: undefined, serverOnly: false, stackable: false, prices: {}, includedItems: { [itemId]: { quantity: 4, repeat: [1, 'week'], expires: 'never' } }, isAddOnTo: false, @@ -237,7 +237,7 @@ describe('getItemQuantityForCustomer - subscriptions', () => { const prisma = createMockPrisma({ subscription: { findMany: async () => [{ - offerId: 'offW', + productId: 'offW', currentPeriodStart: new Date('2025-02-01T00:00:00.000Z'), currentPeriodEnd: new Date('2025-03-01T00:00:00.000Z'), quantity: 1, @@ -262,10 +262,10 @@ describe('getItemQuantityForCustomer - subscriptions', () => { items: { [itemId]: { displayName: 'S', customerType: 'user' }, }, - groups: { g1: { displayName: 'G' } }, - offers: { + catalogs: { g1: { displayName: 'G' } }, + products: { offR: { - displayName: 'O', groupId: 'g1', customerType: 'user', freeTrial: undefined, serverOnly: false, stackable: false, + displayName: 'O', catalogId: 'g1', customerType: 'user', freeTrial: undefined, serverOnly: false, stackable: false, prices: {}, includedItems: { [itemId]: { quantity: 7, repeat: [1, 'week'], expires: 'when-repeated' } }, isAddOnTo: false, @@ -276,7 +276,7 @@ describe('getItemQuantityForCustomer - subscriptions', () => { const prisma = createMockPrisma({ subscription: { findMany: async () => [{ - offerId: 'offR', + productId: 'offR', currentPeriodStart: new Date('2025-02-01T00:00:00.000Z'), currentPeriodEnd: new Date('2025-03-01T00:00:00.000Z'), quantity: 1, @@ -301,10 +301,10 @@ describe('getItemQuantityForCustomer - subscriptions', () => { items: { [itemId]: { displayName: 'S', customerType: 'user' }, }, - groups: { g1: { displayName: 'G' } }, - offers: { + catalogs: { g1: { displayName: 'G' } }, + products: { offN: { - displayName: 'O', groupId: 'g1', customerType: 'user', freeTrial: undefined, serverOnly: false, stackable: false, + displayName: 'O', catalogId: 'g1', customerType: 'user', freeTrial: undefined, serverOnly: false, stackable: false, prices: {}, includedItems: { [itemId]: { quantity: 2, repeat: 'never', expires: 'never' } }, isAddOnTo: false, @@ -315,7 +315,7 @@ describe('getItemQuantityForCustomer - subscriptions', () => { const prisma = createMockPrisma({ subscription: { findMany: async () => [{ - offerId: 'offN', + productId: 'offN', currentPeriodStart: new Date('2025-02-01T00:00:00.000Z'), currentPeriodEnd: new Date('2025-03-01T00:00:00.000Z'), quantity: 3, @@ -336,10 +336,10 @@ describe('getItemQuantityForCustomer - subscriptions', () => { items: { [itemId]: { displayName: 'S', customerType: 'user' }, }, - groups: { g1: { displayName: 'G' } }, - offers: { + catalogs: { g1: { displayName: 'G' } }, + products: { offRC: { - displayName: 'O', groupId: 'g1', customerType: 'user', freeTrial: undefined, serverOnly: false, stackable: false, + displayName: 'O', catalogId: 'g1', customerType: 'user', freeTrial: undefined, serverOnly: false, stackable: false, prices: {}, includedItems: { [itemId]: { quantity: 7, repeat: [1, 'week'], expires: 'when-repeated' } }, isAddOnTo: false, @@ -350,7 +350,7 @@ describe('getItemQuantityForCustomer - subscriptions', () => { const prisma = createMockPrisma({ subscription: { findMany: async () => [{ - offerId: 'offRC', + productId: 'offRC', currentPeriodStart: new Date('2025-02-01T00:00:00.000Z'), currentPeriodEnd: new Date('2025-03-01T00:00:00.000Z'), quantity: 1, @@ -379,10 +379,10 @@ describe('getItemQuantityForCustomer - subscriptions', () => { items: { [itemId]: { displayName: 'S', customerType: 'user' }, }, - groups: { g1: { displayName: 'G' } }, - offers: { + catalogs: { g1: { displayName: 'G' } }, + products: { offRR: { - displayName: 'O', groupId: 'g1', customerType: 'user', freeTrial: undefined, serverOnly: false, stackable: false, + displayName: 'O', catalogId: 'g1', customerType: 'user', freeTrial: undefined, serverOnly: false, stackable: false, prices: {}, includedItems: { [itemId]: { quantity: 7, repeat: [1, 'week'], expires: 'when-repeated' } }, isAddOnTo: false, @@ -398,7 +398,7 @@ describe('getItemQuantityForCustomer - subscriptions', () => { const start = inFirstPeriod ? new Date('2025-02-01T00:00:00.000Z') : new Date('2025-03-01T00:00:00.000Z'); const end = inFirstPeriod ? new Date('2025-03-01T00:00:00.000Z') : new Date('2025-04-01T00:00:00.000Z'); return [{ - offerId: 'offRR', + productId: 'offRR', currentPeriodStart: start, currentPeriodEnd: end, quantity: 1, @@ -428,10 +428,10 @@ describe('getItemQuantityForCustomer - subscriptions', () => { items: { [itemId]: { displayName: 'S', customerType: 'user' }, }, - groups: { g1: { displayName: 'G' } }, - offers: { + catalogs: { g1: { displayName: 'G' } }, + products: { offMD: { - displayName: 'O', groupId: 'g1', customerType: 'user', freeTrial: undefined, serverOnly: false, stackable: false, + displayName: 'O', catalogId: 'g1', customerType: 'user', freeTrial: undefined, serverOnly: false, stackable: false, prices: {}, includedItems: { [itemId]: { quantity: 10, repeat: [1, 'week'], expires: 'when-repeated' } }, isAddOnTo: false, @@ -442,7 +442,7 @@ describe('getItemQuantityForCustomer - subscriptions', () => { const prisma = createMockPrisma({ subscription: { findMany: async () => [{ - offerId: 'offMD', + productId: 'offMD', currentPeriodStart: new Date('2025-02-01T00:00:00.000Z'), currentPeriodEnd: new Date('2025-03-01T00:00:00.000Z'), quantity: 1, @@ -476,10 +476,10 @@ describe('getItemQuantityForCustomer - subscriptions', () => { const itemId = 'subPersistentWhenRepeated'; const tenancy = createMockTenancy({ items: { [itemId]: { displayName: 'S', customerType: 'user' } }, - groups: { g1: { displayName: 'G' } }, - offers: { + catalogs: { g1: { displayName: 'G' } }, + products: { offBF: { - displayName: 'O', groupId: 'g1', customerType: 'user', freeTrial: undefined, serverOnly: false, stackable: false, + displayName: 'O', catalogId: 'g1', customerType: 'user', freeTrial: undefined, serverOnly: false, stackable: false, prices: {}, includedItems: { [itemId]: { quantity: 5, repeat: 'never', expires: 'when-repeated' } }, isAddOnTo: false, @@ -490,7 +490,7 @@ describe('getItemQuantityForCustomer - subscriptions', () => { const prisma = createMockPrisma({ subscription: { findMany: async () => [{ - offerId: 'offBF', + productId: 'offBF', currentPeriodStart: new Date('2025-02-01T00:00:00.000Z'), currentPeriodEnd: new Date('2025-03-01T00:00:00.000Z'), quantity: 1, @@ -524,16 +524,16 @@ describe('getItemQuantityForCustomer - subscriptions', () => { const tenancy = createMockTenancy({ items: { [itemId]: { displayName: 'S', customerType: 'user' } }, - groups: { g1: { displayName: 'G1' }, g2: { displayName: 'G2' } }, - offers: { + catalogs: { g1: { displayName: 'G1' }, g2: { displayName: 'G2' } }, + products: { off1: { - displayName: 'O1', groupId: 'g1', customerType: 'user', freeTrial: undefined, serverOnly: false, stackable: false, + displayName: 'O1', catalogId: 'g1', customerType: 'user', freeTrial: undefined, serverOnly: false, stackable: false, prices: {}, includedItems: { [itemId]: { quantity: 2, repeat: 'never', expires: 'when-purchase-expires' } }, isAddOnTo: false, }, off2: { - displayName: 'O2', groupId: 'g2', customerType: 'user', freeTrial: undefined, serverOnly: false, stackable: false, + displayName: 'O2', catalogId: 'g2', customerType: 'user', freeTrial: undefined, serverOnly: false, stackable: false, prices: {}, includedItems: { [itemId]: { quantity: 1, repeat: 'never', expires: 'when-purchase-expires' } }, isAddOnTo: false, @@ -545,14 +545,14 @@ describe('getItemQuantityForCustomer - subscriptions', () => { subscription: { findMany: async () => [ { - offerId: 'off1', + productId: 'off1', currentPeriodStart: new Date('2025-02-01T00:00:00.000Z'), currentPeriodEnd: new Date('2025-03-01T00:00:00.000Z'), quantity: 3, status: 'active', }, { - offerId: 'off2', + productId: 'off2', currentPeriodStart: new Date('2025-01-15T00:00:00.000Z'), currentPeriodEnd: new Date('2025-03-15T00:00:00.000Z'), quantity: 5, @@ -575,10 +575,10 @@ describe('getItemQuantityForCustomer - subscriptions', () => { const tenancy = createMockTenancy({ items: { [itemA]: { displayName: 'A', customerType: 'user' }, [itemB]: { displayName: 'B', customerType: 'user' } }, - groups: { g1: { displayName: 'G' } }, - offers: { + catalogs: { g1: { displayName: 'G' } }, + products: { offBundle: { - displayName: 'OB', groupId: 'g1', customerType: 'user', freeTrial: undefined, serverOnly: false, stackable: false, + displayName: 'OB', catalogId: 'g1', customerType: 'user', freeTrial: undefined, serverOnly: false, stackable: false, prices: {}, includedItems: { [itemA]: { quantity: 2, repeat: 'never', expires: 'when-purchase-expires' }, @@ -592,7 +592,7 @@ describe('getItemQuantityForCustomer - subscriptions', () => { const prisma = createMockPrisma({ subscription: { findMany: async () => [{ - offerId: 'offBundle', + productId: 'offBundle', currentPeriodStart: new Date('2025-02-01T00:00:00.000Z'), currentPeriodEnd: new Date('2025-03-01T00:00:00.000Z'), quantity: 2, @@ -615,10 +615,10 @@ describe('getItemQuantityForCustomer - subscriptions', () => { const tenancy = createMockTenancy({ items: { [itemId]: { displayName: 'T', customerType: 'user' } }, - groups: { g1: { displayName: 'G' } }, - offers: { + catalogs: { g1: { displayName: 'G' } }, + products: { offT: { - displayName: 'OT', groupId: 'g1', customerType: 'user', freeTrial: undefined, serverOnly: false, stackable: false, + displayName: 'OT', catalogId: 'g1', customerType: 'user', freeTrial: undefined, serverOnly: false, stackable: false, prices: {}, includedItems: { [itemId]: { quantity: 5, repeat: 'never', expires: 'when-purchase-expires' } }, isAddOnTo: false, @@ -629,7 +629,7 @@ describe('getItemQuantityForCustomer - subscriptions', () => { const prisma = createMockPrisma({ subscription: { findMany: async () => [{ - offerId: 'offT', + productId: 'offT', currentPeriodStart: new Date('2025-02-01T00:00:00.000Z'), currentPeriodEnd: new Date('2025-03-01T00:00:00.000Z'), quantity: 3, @@ -650,10 +650,10 @@ describe('getItemQuantityForCustomer - subscriptions', () => { const tenancy = createMockTenancy({ items: { [itemId]: { displayName: 'C', customerType: 'user' } }, - groups: { g1: { displayName: 'G' } }, - offers: { + catalogs: { g1: { displayName: 'G' } }, + products: { offC: { - displayName: 'OC', groupId: 'g1', customerType: 'user', freeTrial: undefined, serverOnly: false, stackable: false, + displayName: 'OC', catalogId: 'g1', customerType: 'user', freeTrial: undefined, serverOnly: false, stackable: false, prices: {}, includedItems: { [itemId]: { quantity: 9, repeat: 'never', expires: 'when-purchase-expires' } }, isAddOnTo: false, @@ -664,7 +664,7 @@ describe('getItemQuantityForCustomer - subscriptions', () => { const prisma = createMockPrisma({ subscription: { findMany: async () => [{ - offerId: 'offC', + productId: 'offC', currentPeriodStart: new Date('2024-12-01T00:00:00.000Z'), currentPeriodEnd: new Date('2025-01-01T00:00:00.000Z'), quantity: 1, @@ -678,18 +678,18 @@ describe('getItemQuantityForCustomer - subscriptions', () => { vi.useRealTimers(); }); - it('ungrouped offer works without tenancy groups', async () => { + it('ungrouped product works without tenancy groups', async () => { const now = new Date('2025-02-10T00:00:00.000Z'); vi.setSystemTime(now); const itemId = 'ungroupedItem'; const tenancy = createMockTenancy({ items: { [itemId]: { displayName: 'U', customerType: 'user' } }, - groups: {}, - offers: { + catalogs: {}, + products: { offU: { displayName: 'OU', - groupId: undefined, + catalogId: undefined, customerType: 'user', freeTrial: undefined, serverOnly: false, @@ -704,7 +704,7 @@ describe('getItemQuantityForCustomer - subscriptions', () => { const prisma = createMockPrisma({ subscription: { findMany: async () => [{ - offerId: 'offU', + productId: 'offU', currentPeriodStart: new Date('2025-02-01T00:00:00.000Z'), currentPeriodEnd: new Date('2025-03-01T00:00:00.000Z'), quantity: 2, @@ -725,15 +725,15 @@ describe('getItemQuantityForCustomer - one-time purchases', () => { const itemId = 'otpItemA'; const tenancy = createMockTenancy({ items: { [itemId]: { displayName: 'I', customerType: 'custom' } }, - offers: {}, - groups: {}, + products: {}, + catalogs: {}, }); const prisma = createMockPrisma({ oneTimePurchase: { findMany: async () => [{ - offerId: 'off-otp', - offer: { includedItems: { [itemId]: { quantity: 5 } } }, + productId: 'off-otp', + product: { includedItems: { [itemId]: { quantity: 5 } } }, quantity: 2, createdAt: new Date('2025-02-10T00:00:00.000Z'), }], @@ -750,19 +750,19 @@ describe('getItemQuantityForCustomer - one-time purchases', () => { expect(qty).toBe(10); }); - it('aggregates multiple one-time purchases across different offers', async () => { + it('aggregates multiple one-time purchases across different products', async () => { const itemId = 'otpItemB'; const tenancy = createMockTenancy({ items: { [itemId]: { displayName: 'I', customerType: 'custom' } }, - offers: {}, - groups: {}, + products: {}, + catalogs: {}, }); const prisma = createMockPrisma({ oneTimePurchase: { findMany: async () => [ - { offerId: 'off-1', offer: { includedItems: { [itemId]: { quantity: 3 } } }, quantity: 1, createdAt: new Date('2025-02-10T00:00:00.000Z') }, - { offerId: 'off-2', offer: { includedItems: { [itemId]: { quantity: 5 } } }, quantity: 2, createdAt: new Date('2025-02-11T00:00:00.000Z') }, + { productId: 'off-1', product: { includedItems: { [itemId]: { quantity: 3 } } }, quantity: 1, createdAt: new Date('2025-02-10T00:00:00.000Z') }, + { productId: 'off-2', product: { includedItems: { [itemId]: { quantity: 5 } } }, quantity: 2, createdAt: new Date('2025-02-11T00:00:00.000Z') }, ], }, } as any); @@ -780,11 +780,11 @@ describe('getItemQuantityForCustomer - one-time purchases', () => { describe('validatePurchaseSession - one-time purchase rules', () => { - it('blocks duplicate one-time purchase for same offerId', async () => { - const tenancy = createMockTenancy({ items: {}, offers: {}, groups: {} }); + it('blocks duplicate one-time purchase for same productId', async () => { + const tenancy = createMockTenancy({ items: {}, products: {}, catalogs: {} }); const prisma = createMockPrisma({ oneTimePurchase: { - findMany: async () => [{ offerId: 'offer-dup', offer: { groupId: undefined }, quantity: 1, createdAt: new Date('2025-01-01T00:00:00.000Z') }], + findMany: async () => [{ productId: 'product-dup', product: { catalogId: undefined }, quantity: 1, createdAt: new Date('2025-01-01T00:00:00.000Z') }], }, subscription: { findMany: async () => [] }, } as any); @@ -795,10 +795,10 @@ describe('validatePurchaseSession - one-time purchase rules', () => { codeData: { tenancyId: tenancy.id, customerId: 'cust-1', - offerId: 'offer-dup', - offer: { + productId: 'product-dup', + product: { displayName: 'X', - groupId: undefined, + catalogId: undefined, customerType: 'custom', freeTrial: undefined, serverOnly: false, @@ -810,14 +810,14 @@ describe('validatePurchaseSession - one-time purchase rules', () => { }, priceId: 'price-any', quantity: 1, - })).rejects.toThrowError('Customer already has a one-time purchase for this offer'); + })).rejects.toThrowError('Customer already has a one-time purchase for this product'); }); it('blocks one-time purchase when another one exists in the same group', async () => { - const tenancy = createMockTenancy({ items: {}, offers: {}, groups: { g1: { displayName: 'G1' } } }); + const tenancy = createMockTenancy({ items: {}, products: {}, catalogs: { g1: { displayName: 'G1' } } }); const prisma = createMockPrisma({ oneTimePurchase: { - findMany: async () => [{ offerId: 'other-offer', offer: { groupId: 'g1' }, quantity: 1, createdAt: new Date('2025-01-01T00:00:00.000Z') }], + findMany: async () => [{ productId: 'other-product', product: { catalogId: 'g1' }, quantity: 1, createdAt: new Date('2025-01-01T00:00:00.000Z') }], }, subscription: { findMany: async () => [] }, } as any); @@ -828,10 +828,10 @@ describe('validatePurchaseSession - one-time purchase rules', () => { codeData: { tenancyId: tenancy.id, customerId: 'cust-1', - offerId: 'offer-y', - offer: { + productId: 'product-y', + product: { displayName: 'Y', - groupId: 'g1', + catalogId: 'g1', customerType: 'custom', freeTrial: undefined, serverOnly: false, @@ -843,14 +843,14 @@ describe('validatePurchaseSession - one-time purchase rules', () => { }, priceId: 'price-any', quantity: 1, - })).rejects.toThrowError('Customer already has a one-time purchase in this offer group'); + })).rejects.toThrowError('Customer already has a one-time purchase in this product catalog'); }); it('allows purchase when existing one-time is in a different group', async () => { - const tenancy = createMockTenancy({ items: {}, offers: {}, groups: { g1: { displayName: 'G1' }, g2: { displayName: 'G2' } } }); + const tenancy = createMockTenancy({ items: {}, products: {}, catalogs: { g1: { displayName: 'G1' }, g2: { displayName: 'G2' } } }); const prisma = createMockPrisma({ oneTimePurchase: { - findMany: async () => [{ offerId: 'other-offer', offer: { groupId: 'g2' }, quantity: 1, createdAt: new Date('2025-01-01T00:00:00.000Z') }], + findMany: async () => [{ productId: 'other-product', product: { catalogId: 'g2' }, quantity: 1, createdAt: new Date('2025-01-01T00:00:00.000Z') }], }, subscription: { findMany: async () => [] }, } as any); @@ -861,10 +861,10 @@ describe('validatePurchaseSession - one-time purchase rules', () => { codeData: { tenancyId: tenancy.id, customerId: 'cust-1', - offerId: 'offer-z', - offer: { + productId: 'product-z', + product: { displayName: 'Z', - groupId: 'g1', + catalogId: 'g1', customerType: 'custom', freeTrial: undefined, serverOnly: false, @@ -877,8 +877,8 @@ describe('validatePurchaseSession - one-time purchase rules', () => { priceId: 'price-any', quantity: 1, }); - expect(res.groupId).toBe('g1'); - expect(res.conflictingGroupSubscriptions.length).toBe(0); + expect(res.catalogId).toBe('g1'); + expect(res.conflictingCatalogSubscriptions.length).toBe(0); }); }); @@ -890,10 +890,10 @@ describe('combined sources - one-time purchases + manual changes + subscriptions const itemId = 'comboItem'; const tenancy = createMockTenancy({ items: { [itemId]: { displayName: 'Combo', customerType: 'user' } }, - groups: { g1: { displayName: 'G' } }, - offers: { + catalogs: { g1: { displayName: 'G' } }, + products: { offSub: { - displayName: 'Sub', groupId: 'g1', customerType: 'user', freeTrial: undefined, serverOnly: false, stackable: false, + displayName: 'Sub', catalogId: 'g1', customerType: 'user', freeTrial: undefined, serverOnly: false, stackable: false, prices: {}, includedItems: { [itemId]: { quantity: 5, repeat: 'never', expires: 'when-purchase-expires' } }, isAddOnTo: false, @@ -911,13 +911,13 @@ describe('combined sources - one-time purchases + manual changes + subscriptions }, oneTimePurchase: { findMany: async () => [ - { offerId: 'offA', offer: { includedItems: { [itemId]: { quantity: 4 } } }, quantity: 1, createdAt: new Date('2025-02-09T00:00:00.000Z') }, - { offerId: 'offB', offer: { includedItems: { [itemId]: { quantity: 2 } } }, quantity: 3, createdAt: new Date('2025-02-11T00:00:00.000Z') }, + { productId: 'offA', product: { includedItems: { [itemId]: { quantity: 4 } } }, quantity: 1, createdAt: new Date('2025-02-09T00:00:00.000Z') }, + { productId: 'offB', product: { includedItems: { [itemId]: { quantity: 2 } } }, quantity: 3, createdAt: new Date('2025-02-11T00:00:00.000Z') }, ], }, subscription: { findMany: async () => [{ - offerId: 'offSub', + productId: 'offSub', currentPeriodStart: new Date('2025-02-01T00:00:00.000Z'), currentPeriodEnd: new Date('2025-03-01T00:00:00.000Z'), quantity: 2, diff --git a/apps/backend/src/lib/payments.tsx b/apps/backend/src/lib/payments.tsx index c9f4b3a5f5..8c38cda976 100644 --- a/apps/backend/src/lib/payments.tsx +++ b/apps/backend/src/lib/payments.tsx @@ -1,9 +1,9 @@ import { PrismaClientTransaction } from "@/prisma-client"; import { SubscriptionStatus } from "@prisma/client"; import { KnownErrors } from "@stackframe/stack-shared"; -import type { inlineOfferSchema, offerSchema } from "@stackframe/stack-shared/dist/schema-fields"; +import type { inlineProductSchema, productSchema } from "@stackframe/stack-shared/dist/schema-fields"; import { SUPPORTED_CURRENCIES } from "@stackframe/stack-shared/dist/utils/currency-constants"; -import { addInterval, FAR_FUTURE_DATE, getIntervalsElapsed } from "@stackframe/stack-shared/dist/utils/dates"; +import { FAR_FUTURE_DATE, addInterval, getIntervalsElapsed } from "@stackframe/stack-shared/dist/utils/dates"; import { StackAssertionError, StatusError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; import { getOrUndefined, typedEntries, typedFromEntries, typedKeys } from "@stackframe/stack-shared/dist/utils/objects"; import { typedToUppercase } from "@stackframe/stack-shared/dist/utils/strings"; @@ -12,48 +12,48 @@ import Stripe from "stripe"; import * as yup from "yup"; import { Tenancy } from "./tenancies"; -const DEFAULT_OFFER_START_DATE = new Date("1973-01-01T12:00:00.000Z"); // monday +const DEFAULT_PRODUCT_START_DATE = new Date("1973-01-01T12:00:00.000Z"); // monday -export async function ensureOfferIdOrInlineOffer( +export async function ensureProductIdOrInlineProduct( tenancy: Tenancy, accessType: "client" | "server" | "admin", - offerId: string | undefined, - inlineOffer: yup.InferType | undefined -): Promise { - if (offerId && inlineOffer) { - throw new StatusError(400, "Cannot specify both offer_id and offer_inline!"); + productId: string | undefined, + inlineProduct: yup.InferType | undefined +): Promise { + if (productId && inlineProduct) { + throw new StatusError(400, "Cannot specify both product_id and product_inline!"); } - if (inlineOffer && accessType === "client") { - throw new StatusError(400, "Cannot specify offer_inline when calling from client! Please call with a server API key, or use the offer_id parameter."); + if (inlineProduct && accessType === "client") { + throw new StatusError(400, "Cannot specify product_inline when calling from client! Please call with a server API key, or use the product_id parameter."); } - if (!offerId && !inlineOffer) { - throw new StatusError(400, "Must specify either offer_id or offer_inline!"); + if (!productId && !inlineProduct) { + throw new StatusError(400, "Must specify either product_id or product_inline!"); } - if (offerId) { - const offer = getOrUndefined(tenancy.config.payments.offers, offerId); - if (!offer || (offer.serverOnly && accessType === "client")) { - throw new KnownErrors.OfferDoesNotExist(offerId, accessType); + if (productId) { + const product = getOrUndefined(tenancy.config.payments.products, productId); + if (!product || (product.serverOnly && accessType === "client")) { + throw new KnownErrors.ProductDoesNotExist(productId, accessType); } - return offer; + return product; } else { - if (!inlineOffer) { - throw new StackAssertionError("Inline offer does not exist, this should never happen", { inlineOffer, offerId }); + if (!inlineProduct) { + throw new StackAssertionError("Inline product does not exist, this should never happen", { inlineProduct, productId }); } return { - groupId: undefined, + catalogId: undefined, isAddOnTo: false, - displayName: inlineOffer.display_name, - customerType: inlineOffer.customer_type, - freeTrial: inlineOffer.free_trial, - serverOnly: inlineOffer.server_only, + displayName: inlineProduct.display_name, + customerType: inlineProduct.customer_type, + freeTrial: inlineProduct.free_trial, + serverOnly: inlineProduct.server_only, stackable: false, - prices: Object.fromEntries(Object.entries(inlineOffer.prices).map(([key, value]) => [key, { + prices: Object.fromEntries(Object.entries(inlineProduct.prices).map(([key, value]) => [key, { ...typedFromEntries(SUPPORTED_CURRENCIES.map(c => [c.code, getOrUndefined(value, c.code)])), interval: value.interval, freeTrial: value.free_trial, serverOnly: true, }])), - includedItems: typedFromEntries(Object.entries(inlineOffer.included_items).map(([key, value]) => [key, { + includedItems: typedFromEntries(Object.entries(inlineProduct.included_items).map(([key, value]) => [key, { repeat: value.repeat ?? "never", quantity: value.quantity ?? 0, expires: value.expires ?? "never", @@ -169,8 +169,8 @@ export async function getItemQuantityForCustomer(options: { }, }); for (const p of oneTimePurchases) { - const offer = p.offer as yup.InferType; - const inc = getOrUndefined(offer.includedItems, options.itemId); + const product = p.product as yup.InferType; + const inc = getOrUndefined(product.includedItems, options.itemId); if (!inc) continue; const baseQty = inc.quantity * p.quantity; if (baseQty <= 0) continue; @@ -189,8 +189,8 @@ export async function getItemQuantityForCustomer(options: { customerId: options.customerId, }); for (const s of subscriptions) { - const offer = s.offer; - const inc = getOrUndefined(offer.includedItems, options.itemId); + const product = s.product; + const inc = getOrUndefined(product.includedItems, options.itemId); if (!inc) continue; const baseQty = inc.quantity * s.quantity; if (baseQty <= 0) continue; @@ -242,14 +242,14 @@ type Subscription = { */ id: string | null, /** - * `null` for inline offers + * `null` for inline products */ - offerId: string | null, + productId: string | null, /** - * `null` for test mode purchases and group default offers + * `null` for test mode purchases and catalog default products */ stripeSubscriptionId: string | null, - offer: yup.InferType, + product: yup.InferType, quantity: number, currentPeriodStart: Date, currentPeriodEnd: Date | null, @@ -267,8 +267,8 @@ export async function getSubscriptions(options: { customerType: "user" | "team" | "custom", customerId: string, }) { - const groups = options.tenancy.config.payments.groups; - const offers = options.tenancy.config.payments.offers; + const catalogs = options.tenancy.config.payments.catalogs; + const products = options.tenancy.config.payments.products; const subscriptions: Subscription[] = []; const dbSubscriptions = await options.prisma.subscription.findMany({ where: { @@ -278,14 +278,14 @@ export async function getSubscriptions(options: { }, }); - const groupsWithDbSubscriptions = new Set(); + const catalogsWithDbSubscriptions = new Set(); for (const s of dbSubscriptions) { - const offer = s.offerId ? getOrUndefined(offers, s.offerId) : s.offer as yup.InferType; - if (!offer) continue; + const product = s.productId ? getOrUndefined(products, s.productId) : s.product as yup.InferType; + if (!product) continue; subscriptions.push({ id: s.id, - offerId: s.offerId, - offer, + productId: s.productId, + product, quantity: s.quantity, currentPeriodStart: s.currentPeriodStart, currentPeriodEnd: s.currentPeriodEnd, @@ -293,25 +293,25 @@ export async function getSubscriptions(options: { createdAt: s.createdAt, stripeSubscriptionId: s.stripeSubscriptionId, }); - if (offer.groupId !== undefined) { - groupsWithDbSubscriptions.add(offer.groupId); + if (product.catalogId !== undefined) { + catalogsWithDbSubscriptions.add(product.catalogId); } } - for (const groupId of Object.keys(groups)) { - if (groupsWithDbSubscriptions.has(groupId)) continue; - const offersInGroup = typedEntries(offers).filter(([_, offer]) => offer.groupId === groupId); - const defaultGroupOffer = offersInGroup.find(([_, offer]) => offer.prices === "include-by-default"); - if (defaultGroupOffer) { + for (const catalogId of Object.keys(catalogs)) { + if (catalogsWithDbSubscriptions.has(catalogId)) continue; + const productsInCatalog = typedEntries(products).filter(([_, product]) => product.catalogId === catalogId); + const defaultCatalogProduct = productsInCatalog.find(([_, product]) => product.prices === "include-by-default"); + if (defaultCatalogProduct) { subscriptions.push({ id: null, - offerId: defaultGroupOffer[0], - offer: defaultGroupOffer[1], + productId: defaultCatalogProduct[0], + product: defaultCatalogProduct[1], quantity: 1, - currentPeriodStart: DEFAULT_OFFER_START_DATE, + currentPeriodStart: DEFAULT_PRODUCT_START_DATE, currentPeriodEnd: null, status: SubscriptionStatus.active, - createdAt: DEFAULT_OFFER_START_DATE, + createdAt: DEFAULT_PRODUCT_START_DATE, stripeSubscriptionId: null, }); } @@ -359,8 +359,8 @@ export async function ensureCustomerExists(options: { } } -type Offer = yup.InferType; -type SelectedPrice = Exclude[string]; +type Product = yup.InferType; +type SelectedPrice = Exclude[string]; export async function validatePurchaseSession(options: { prisma: PrismaClientTransaction, @@ -368,36 +368,36 @@ export async function validatePurchaseSession(options: { codeData: { tenancyId: string, customerId: string, - offerId?: string, - offer: Offer, + productId?: string, + product: Product, }, priceId: string, quantity: number, }): Promise<{ selectedPrice: SelectedPrice | undefined, - groupId: string | undefined, + catalogId: string | undefined, subscriptions: Subscription[], - conflictingGroupSubscriptions: Subscription[], + conflictingCatalogSubscriptions: Subscription[], }> { const { prisma, tenancy, codeData, priceId, quantity } = options; - const offer = codeData.offer; + const product = codeData.product; await ensureCustomerExists({ prisma, tenancyId: tenancy.id, - customerType: offer.customerType, + customerType: product.customerType, customerId: codeData.customerId, }); let selectedPrice: SelectedPrice | undefined = undefined; - if (offer.prices !== "include-by-default") { - const pricesMap = new Map(typedEntries(offer.prices)); + if (product.prices !== "include-by-default") { + const pricesMap = new Map(typedEntries(product.prices)); selectedPrice = pricesMap.get(priceId); if (!selectedPrice) { - throw new StatusError(400, "Price not found on offer associated with this purchase code"); + throw new StatusError(400, "Price not found on product associated with this purchase code"); } } - if (quantity !== 1 && offer.stackable !== true) { - throw new StatusError(400, "This offer is not stackable; quantity must be 1"); + if (quantity !== 1 && product.stackable !== true) { + throw new StatusError(400, "This product is not stackable; quantity must be 1"); } // Block based on prior one-time purchases for same customer and customerType @@ -405,55 +405,55 @@ export async function validatePurchaseSession(options: { where: { tenancyId: tenancy.id, customerId: codeData.customerId, - customerType: typedToUppercase(offer.customerType), + customerType: typedToUppercase(product.customerType), }, }); - if (codeData.offerId && existingOneTimePurchases.some((p) => p.offerId === codeData.offerId)) { - throw new StatusError(400, "Customer already has a one-time purchase for this offer"); + if (codeData.productId && existingOneTimePurchases.some((p) => p.productId === codeData.productId)) { + throw new StatusError(400, "Customer already has a one-time purchase for this product"); } const subscriptions = await getSubscriptions({ prisma, tenancy, - customerType: offer.customerType, + customerType: product.customerType, customerId: codeData.customerId, }); - if (subscriptions.find((s) => s.offerId === codeData.offerId) && offer.stackable !== true) { - throw new StatusError(400, "Customer already has a subscription for this offer; this offer is not stackable"); + if (subscriptions.find((s) => s.productId === codeData.productId) && product.stackable !== true) { + throw new StatusError(400, "Customer already has a subscription for this product; this product is not stackable"); } - const addOnOfferIds = offer.isAddOnTo ? typedKeys(offer.isAddOnTo) : []; - if (offer.isAddOnTo && !subscriptions.some((s) => s.offerId && addOnOfferIds.includes(s.offerId))) { - throw new StatusError(400, "This offer is an add-on to an offer that the customer does not have"); + const addOnProductIds = product.isAddOnTo ? typedKeys(product.isAddOnTo) : []; + if (product.isAddOnTo && !subscriptions.some((s) => s.productId && addOnProductIds.includes(s.productId))) { + throw new StatusError(400, "This product is an add-on to a product that the customer does not have"); } - const groups = tenancy.config.payments.groups; - const groupId = typedKeys(groups).find((g) => offer.groupId === g); + const catalogs = tenancy.config.payments.catalogs; + const catalogId = typedKeys(catalogs).find((g) => product.catalogId === g); - // Block purchasing any offer in the same group if a one-time purchase exists in that group - if (groupId) { - const hasOneTimeInGroup = existingOneTimePurchases.some((p) => { - const offer = p.offer as yup.InferType; - return offer.groupId === groupId; + // Block purchasing any product in the same catalog if a one-time purchase exists in that catalog + if (catalogId) { + const hasOneTimeInCatalog = existingOneTimePurchases.some((p) => { + const product = p.product as yup.InferType; + return product.catalogId === catalogId; }); - if (hasOneTimeInGroup) { - throw new StatusError(400, "Customer already has a one-time purchase in this offer group"); + if (hasOneTimeInCatalog) { + throw new StatusError(400, "Customer already has a one-time purchase in this product catalog"); } } - let conflictingGroupSubscriptions: Subscription[] = []; - if (groupId) { - conflictingGroupSubscriptions = subscriptions.filter((subscription) => ( + let conflictingCatalogSubscriptions: Subscription[] = []; + if (catalogId) { + conflictingCatalogSubscriptions = subscriptions.filter((subscription) => ( subscription.id && - subscription.offerId && - subscription.offer.groupId === groupId && + subscription.productId && + subscription.product.catalogId === catalogId && isActiveSubscription(subscription) && - subscription.offer.prices !== "include-by-default" && - (!offer.isAddOnTo || !addOnOfferIds.includes(subscription.offerId)) + subscription.product.prices !== "include-by-default" && + (!product.isAddOnTo || !addOnProductIds.includes(subscription.productId)) )); } - return { selectedPrice, groupId, subscriptions, conflictingGroupSubscriptions }; + return { selectedPrice, catalogId, subscriptions, conflictingCatalogSubscriptions }; } export function getClientSecretFromStripeSubscription(subscription: Stripe.Subscription): string { diff --git a/apps/backend/src/lib/stripe.tsx b/apps/backend/src/lib/stripe.tsx index f452228fd7..3ab57d2844 100644 --- a/apps/backend/src/lib/stripe.tsx +++ b/apps/backend/src/lib/stripe.tsx @@ -89,7 +89,7 @@ export async function syncStripeSubscriptions(stripe: Stripe, stripeAccountId: s }, update: { status: subscription.status, - offer: JSON.parse(subscription.metadata.offer), + product: JSON.parse(subscription.metadata.product), quantity: item.quantity ?? 1, currentPeriodEnd: new Date(item.current_period_end * 1000), currentPeriodStart: new Date(item.current_period_start * 1000), @@ -100,9 +100,9 @@ export async function syncStripeSubscriptions(stripe: Stripe, stripeAccountId: s tenancyId: tenancy.id, customerId, customerType, - offerId: subscription.metadata.offerId, + productId: subscription.metadata.productId, priceId: priceId ?? null, - offer: JSON.parse(subscription.metadata.offer), + product: JSON.parse(subscription.metadata.product), quantity: item.quantity ?? 1, stripeSubscriptionId: subscription.id, status: subscription.status, diff --git a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/page-client.tsx index 2ab066e7fa..5333a69fa7 100644 --- a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/page-client.tsx @@ -134,7 +134,7 @@ function TeamAddUserDialog(props: { const onSubmit = async (values: yup.InferType) => { if (users.length + 1 > quantity) { alert("You have reached the maximum number of dashboard admins. Please upgrade your plan to add more admins."); - const checkoutUrl = await props.team.createCheckoutUrl({ offerId: "team" }); + const checkoutUrl = await props.team.createCheckoutUrl({ productId: "team" }); window.open(checkoutUrl, "_blank", "noopener"); return "prevent-close-and-prevent-reset"; } diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offers/dummy-data.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offers/dummy-data.tsx deleted file mode 100644 index 64438ba473..0000000000 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offers/dummy-data.tsx +++ /dev/null @@ -1,278 +0,0 @@ -// Dummy data for development -export const DUMMY_PAYMENTS_CONFIG: any = { - groups: { - "basic-plans": { displayName: "Basic Plans" }, - "pro-plans": { displayName: "Professional Plans" }, - "enterprise": { displayName: "Enterprise" }, - "add-ons": { displayName: "Add-ons" }, - }, - offers: { - "free-trial": { - displayName: "Free Trial", - customerType: "user" as const, - groupId: "basic-plans", - freeTrial: [14, "day"] as [number, "day"], - stackable: false, - serverOnly: false, - prices: "include-by-default" as const, - includedItems: { - "basic-features": { quantity: 1 }, - "cloud-storage-5gb": { quantity: 1 }, - }, - }, - "starter": { - displayName: "Starter", - customerType: "user" as const, - groupId: "basic-plans", - stackable: false, - serverOnly: false, - prices: { - "monthly": { USD: "9.99", interval: [1, "month"] as [number, "month"] }, - "yearly": { USD: "99.90", interval: [1, "year"] as [number, "year"] }, - }, - includedItems: { - "basic-features": { quantity: 1 }, - "cloud-storage-10gb": { quantity: 1 }, - "email-support": { quantity: 1 }, - "api-calls": { quantity: 1000 }, - }, - }, - "professional": { - displayName: "Professional", - customerType: "user" as const, - groupId: "pro-plans", - stackable: false, - serverOnly: false, - prices: { - "monthly": { USD: "29.99", interval: [1, "month"] as [number, "month"] }, - "yearly": { USD: "299.90", interval: [1, "year"] as [number, "year"] }, - "quarterly": { USD: "89.97", interval: [3, "month"] as [number, "month"] }, - }, - includedItems: { - "pro-features": { quantity: 1 }, - "cloud-storage-100gb": { quantity: 1 }, - "priority-support": { quantity: 1 }, - "api-calls": { quantity: 10000 }, - "team-members": { quantity: 5 }, - "custom-domain": { quantity: 1 }, - }, - }, - "business": { - displayName: "Business", - customerType: "team" as const, - groupId: "pro-plans", - stackable: false, - serverOnly: false, - prices: { - "monthly": { USD: "99.99", interval: [1, "month"] as [number, "month"] }, - "yearly": { USD: "999.90", interval: [1, "year"] as [number, "year"] }, - }, - includedItems: { - "pro-features": { quantity: 1 }, - "cloud-storage-1tb": { quantity: 1 }, - "priority-support": { quantity: 1 }, - "api-calls": { quantity: 100000 }, - "team-members": { quantity: 20 }, - "custom-domain": { quantity: 3 }, - "advanced-analytics": { quantity: 1 }, - "sso": { quantity: 1 }, - }, - }, - "enterprise-standard": { - displayName: "Enterprise Standard", - customerType: "team" as const, - groupId: "enterprise", - stackable: false, - serverOnly: false, - prices: { - "yearly": { USD: "2999.00", interval: [1, "year"] as [number, "year"] }, - }, - includedItems: { - "enterprise-features": { quantity: 1 }, - "cloud-storage-unlimited": { quantity: 1 }, - "dedicated-support": { quantity: 1 }, - "api-calls": { quantity: 1000000 }, - "team-members": { quantity: 100 }, - "custom-domain": { quantity: 10 }, - "advanced-analytics": { quantity: 1 }, - "sso": { quantity: 1 }, - "audit-logs": { quantity: 1 }, - "sla": { quantity: 1 }, - }, - }, - "enterprise-plus": { - displayName: "Enterprise Plus", - customerType: "custom" as const, - groupId: "enterprise", - stackable: false, - serverOnly: false, - prices: { - "custom": { USD: "0.00" }, - }, - includedItems: { - "enterprise-features": { quantity: 1 }, - "cloud-storage-unlimited": { quantity: 1 }, - "white-glove-support": { quantity: 1 }, - "api-calls": { quantity: 999999 }, - "team-members": { quantity: 999 }, - "custom-domain": { quantity: 999 }, - "advanced-analytics": { quantity: 1 }, - "sso": { quantity: 1 }, - "audit-logs": { quantity: 1 }, - "sla": { quantity: 1 }, - "custom-integrations": { quantity: 1 }, - "dedicated-infrastructure": { quantity: 1 }, - }, - }, - "extra-storage": { - displayName: "Extra Storage", - customerType: "user" as const, - groupId: "add-ons", - stackable: true, - serverOnly: false, - prices: { - "monthly": { USD: "4.99", interval: [1, "month"] as [number, "month"] }, - }, - includedItems: { - "cloud-storage-50gb": { quantity: 1 }, - }, - }, - "additional-api-calls": { - displayName: "API Call Pack", - customerType: "user" as const, - groupId: "add-ons", - stackable: true, - serverOnly: false, - prices: { - "monthly": { USD: "9.99", interval: [1, "month"] as [number, "month"] }, - }, - includedItems: { - "api-calls": { quantity: 5000 }, - }, - }, - "team-member-addon": { - displayName: "Extra Team Member", - customerType: "team" as const, - groupId: "add-ons", - stackable: true, - serverOnly: false, - prices: { - "monthly": { USD: "14.99", interval: [1, "month"] as [number, "month"] }, - }, - includedItems: { - "team-members": { quantity: 1 }, - }, - }, - "premium-support": { - displayName: "Premium Support", - customerType: "team" as const, - stackable: false, - serverOnly: false, - prices: { - "monthly": { USD: "299.00", interval: [1, "month"] as [number, "month"] }, - }, - includedItems: { - "24-7-support": { quantity: 1 }, - "dedicated-account-manager": { quantity: 1 }, - }, - }, - }, - items: { - "basic-features": { - displayName: "Basic Features", - customerType: "user" as const, - }, - "pro-features": { - displayName: "Professional Features", - customerType: "user" as const, - }, - "enterprise-features": { - displayName: "Enterprise Features", - customerType: "team" as const, - }, - "cloud-storage-5gb": { - displayName: "5GB Cloud Storage", - customerType: "user" as const, - }, - "cloud-storage-10gb": { - displayName: "10GB Cloud Storage", - customerType: "user" as const, - }, - "cloud-storage-50gb": { - displayName: "50GB Cloud Storage", - customerType: "user" as const, - }, - "cloud-storage-100gb": { - displayName: "100GB Cloud Storage", - customerType: "user" as const, - }, - "cloud-storage-1tb": { - displayName: "1TB Cloud Storage", - customerType: "team" as const, - }, - "cloud-storage-unlimited": { - displayName: "Unlimited Cloud Storage", - customerType: "team" as const, - }, - "email-support": { - displayName: "Email Support", - customerType: "user" as const, - }, - "priority-support": { - displayName: "Priority Support", - customerType: "user" as const, - }, - "dedicated-support": { - displayName: "Dedicated Support", - customerType: "team" as const, - }, - "white-glove-support": { - displayName: "White Glove Support", - customerType: "custom" as const, - }, - "24-7-support": { - displayName: "24/7 Phone Support", - customerType: "team" as const, - }, - "api-calls": { - displayName: "API Calls", - customerType: "user" as const, - }, - "team-members": { - displayName: "Team Members", - customerType: "team" as const, - }, - "custom-domain": { - displayName: "Custom Domain", - customerType: "user" as const, - }, - "advanced-analytics": { - displayName: "Advanced Analytics", - customerType: "team" as const, - }, - "sso": { - displayName: "Single Sign-On (SSO)", - customerType: "team" as const, - }, - "audit-logs": { - displayName: "Audit Logs", - customerType: "team" as const, - }, - "sla": { - displayName: "Service Level Agreement", - customerType: "team" as const, - }, - "custom-integrations": { - displayName: "Custom Integrations", - customerType: "custom" as const, - }, - "dedicated-infrastructure": { - displayName: "Dedicated Infrastructure", - customerType: "custom" as const, - }, - "dedicated-account-manager": { - displayName: "Dedicated Account Manager", - customerType: "team" as const, - }, - }, -}; diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/page.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/page.tsx index e9801af08a..aaeb7eb77b 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/page.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/page.tsx @@ -1,5 +1,5 @@ import { redirect } from "next/navigation"; export default function Page() { - redirect("./payments/offers"); + redirect("./payments/products"); } diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offers/create-group-dialog.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/create-catalog-dialog.tsx similarity index 70% rename from apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offers/create-group-dialog.tsx rename to apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/create-catalog-dialog.tsx index 2d6ebf9fb6..297fc64f76 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offers/create-group-dialog.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/create-catalog-dialog.tsx @@ -3,25 +3,25 @@ import { Button, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, Input, Label, SimpleTooltip, Typography } from "@stackframe/stack-ui"; import { useState } from "react"; -type CreateGroupDialogProps = { +type CreateCatalogDialogProps = { open: boolean, onOpenChange: (open: boolean) => void, - onCreate: (group: { id: string, displayName: string }) => void, + onCreate: (catalog: { id: string, displayName: string }) => void, }; -export function CreateGroupDialog({ open, onOpenChange, onCreate }: CreateGroupDialogProps) { - const [groupId, setGroupId] = useState(""); +export function CreateCatalogDialog({ open, onOpenChange, onCreate }: CreateCatalogDialogProps) { + const [catalogId, setCatalogId] = useState(""); const [displayName, setDisplayName] = useState(""); const [errors, setErrors] = useState<{ id?: string, displayName?: string }>({}); const validateAndCreate = () => { const newErrors: { id?: string, displayName?: string } = {}; - // Validate group ID - if (!groupId.trim()) { - newErrors.id = "Group ID is required"; - } else if (!/^[a-z0-9-]+$/.test(groupId)) { - newErrors.id = "Group ID must contain only lowercase letters, numbers, and hyphens"; + // Validate catalog ID + if (!catalogId.trim()) { + newErrors.id = "Catalog ID is required"; + } else if (!/^[a-z0-9-]+$/.test(catalogId)) { + newErrors.id = "Catalog ID must contain only lowercase letters, numbers, and hyphens"; } // Validate display name @@ -34,17 +34,17 @@ export function CreateGroupDialog({ open, onOpenChange, onCreate }: CreateGroupD return; } - onCreate({ id: groupId.trim(), displayName: displayName.trim() }); + onCreate({ id: catalogId.trim(), displayName: displayName.trim() }); // Reset form - setGroupId(""); + setCatalogId(""); setDisplayName(""); setErrors({}); onOpenChange(false); }; const handleClose = () => { - setGroupId(""); + setCatalogId(""); setDisplayName(""); setErrors({}); onOpenChange(false); @@ -54,24 +54,24 @@ export function CreateGroupDialog({ open, onOpenChange, onCreate }: CreateGroupD - Create Offer Group + Create Product Catalog - Offer groups allow you to organize related offers. Customers can only have one active offer from each group at a time (except for add-ons). + Product catalogs allow you to organize related products. Customers can only have one active product from each catalog at a time (except for add-ons).
-
{/* Sub-dialogs */} - { - // In a real app, you'd save the group to the backend - setGroupId(group.id); - setShowGroupDialog(false); + { + // In a real app, you'd save the catalog to the backend + setCatalogId(catalog.id); + setShowCatalogDialog(false); }} /> diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx index 046ec26c60..021876f3d6 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx @@ -247,9 +247,9 @@ const navigationItems: (Label | Item | Hidden)[] = [ type: 'label', }, { - name: "Offers", - href: "/payments/offers", - regex: /^\/projects\/[^\/]+\/payments\/offers$/, + name: "Products", + href: "/payments/products", + regex: /^\/projects\/[^\/]+\/payments\/products$/, icon: CreditCard, type: 'item', }, diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/workflows/workflow-list.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/workflows/workflow-list.tsx index 476628a75d..ef7e62ee3e 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/workflows/workflow-list.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/workflows/workflow-list.tsx @@ -3,7 +3,7 @@ import { cn } from "@/lib/utils"; import { Button, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@stackframe/stack-ui"; import { MoreVertical } from "lucide-react"; import { useState } from "react"; -import { ListSection } from "../payments/offers/list-section"; +import { ListSection } from "../payments/products/list-section"; type Workflow = { id: string, diff --git a/apps/dashboard/src/app/(main)/purchase/[code]/page-client.tsx b/apps/dashboard/src/app/(main)/purchase/[code]/page-client.tsx index 5100d0c2b5..4dbc6bc970 100644 --- a/apps/dashboard/src/app/(main)/purchase/[code]/page-client.tsx +++ b/apps/dashboard/src/app/(main)/purchase/[code]/page-client.tsx @@ -4,7 +4,7 @@ import { CheckoutForm } from "@/components/payments/checkout"; import { StripeElementsProvider } from "@/components/payments/stripe-elements-provider"; import { getPublicEnvVar } from "@/lib/env"; import { StackAdminApp, useUser } from "@stackframe/stack"; -import { inlineOfferSchema } from "@stackframe/stack-shared/dist/schema-fields"; +import { inlineProductSchema } from "@stackframe/stack-shared/dist/schema-fields"; import { throwErr } from "@stackframe/stack-shared/dist/utils/errors"; import { typedEntries } from "@stackframe/stack-shared/dist/utils/objects"; import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises"; @@ -13,19 +13,19 @@ import { ArrowRight, Minus, Plus } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; import * as yup from "yup"; -type OfferData = { - offer?: Omit, "included_items" | "server_only"> & { stackable: boolean }, +type ProductData = { + product?: Omit, "included_items" | "server_only"> & { stackable: boolean }, stripe_account_id: string, project_id: string, already_bought_non_stackable?: boolean, - conflicting_group_offers?: { offer_id: string, display_name: string }[], + conflicting_products?: { product_id: string, display_name: string }[], }; const apiUrl = getPublicEnvVar("NEXT_PUBLIC_STACK_API_URL") ?? throwErr("NEXT_PUBLIC_STACK_API_URL is not set"); const baseUrl = new URL("/api/v1", apiUrl).toString(); export default function PageClient({ code }: { code: string }) { - const [data, setData] = useState(null); + const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [selectedPriceId, setSelectedPriceId] = useState(null); @@ -52,10 +52,10 @@ export default function PageClient({ code }: { code: string }) { }, [quantityInput]); const unitCents = useMemo((): number => { - if (!selectedPriceId || !data?.offer?.prices) { + if (!selectedPriceId || !data?.product?.prices) { return 0; } - return Number(data.offer.prices[selectedPriceId].USD) * 100; + return Number(data.product.prices[selectedPriceId].USD) * 100; }, [data, selectedPriceId]); const MAX_STRIPE_AMOUNT_CENTS = 999_999 * 100; @@ -74,8 +74,8 @@ export default function PageClient({ code }: { code: string }) { }, [unitCents, rawAmountCents, isTooLarge, MAX_STRIPE_AMOUNT_CENTS]); const elementsMode = useMemo<"subscription" | "payment">(() => { - if (!selectedPriceId || !data?.offer?.prices) return "subscription"; - const price = data.offer.prices[selectedPriceId]; + if (!selectedPriceId || !data?.product?.prices) return "subscription"; + const price = data.product.prices[selectedPriceId]; return price.interval ? "subscription" : "payment"; }, [data, selectedPriceId]); @@ -99,8 +99,8 @@ export default function PageClient({ code }: { code: string }) { } const result = await response.json(); setData(result); - if (result?.offer?.prices) { - const firstPriceId = Object.keys(result.offer.prices)[0]; + if (result?.product?.prices) { + const firstPriceId = Object.keys(result.product.prices)[0]; setSelectedPriceId(firstPriceId); } }, [code]); @@ -156,29 +156,29 @@ export default function PageClient({ code }: { code: string }) { ) : ( <>
- {data?.offer?.display_name || "Plan"} + {data?.product?.display_name || "Plan"}
{data?.already_bought_non_stackable ? ( Already purchased - You already have this offer. + You already have this product. - ) : data?.conflicting_group_offers && data.conflicting_group_offers.length > 0 ? ( + ) : data?.conflicting_products && data.conflicting_products.length > 0 ? ( Plan change - {data.conflicting_group_offers.length === 1 ? ( - <>This purchase will change your plan from {data.conflicting_group_offers[0].display_name}. + {data.conflicting_products.length === 1 ? ( + <>This purchase will change your plan from {data.conflicting_products[0].display_name}. ) : ( <>This purchase will change your plan from one of your existing plans. )} ) : null} - {data?.offer?.prices && typedEntries(data.offer.prices).map(([priceId, priceData]) => ( + {data?.product?.prices && typedEntries(data.product.prices).map(([priceId, priceData]) => ( ))} - {data?.offer?.stackable && selectedPriceId && ( + {data?.product?.stackable && selectedPriceId && (
Quantity @@ -251,10 +251,10 @@ export default function PageClient({ code }: { code: string }) {
Total - ${selectedPriceId ? (Number(data.offer.prices[selectedPriceId].USD) * Math.max(0, quantityNumber)) : 0} - {selectedPriceId && data.offer.prices[selectedPriceId].interval && ( + ${selectedPriceId ? (Number(data.product.prices[selectedPriceId].USD) * Math.max(0, quantityNumber)) : 0} + {selectedPriceId && data.product.prices[selectedPriceId].interval && ( - {" "}/ {shortenedInterval(data.offer.prices[selectedPriceId].interval!)} + {" "}/ {shortenedInterval(data.product.prices[selectedPriceId].interval!)} )} diff --git a/apps/dashboard/src/components/data-table/payment-item-table.tsx b/apps/dashboard/src/components/data-table/payment-item-table.tsx index e4d228dd26..9142002ac9 100644 --- a/apps/dashboard/src/components/data-table/payment-item-table.tsx +++ b/apps/dashboard/src/components/data-table/payment-item-table.tsx @@ -105,11 +105,11 @@ function ActionsCell({ item }: { item: PaymentItem }) { label: "Delete", onClick: async () => { const config = await project.getConfig(); - for (const [offerId, offer] of Object.entries(config.payments.offers)) { - if (has(offer.includedItems, item.id)) { + for (const [productId, product] of Object.entries(config.payments.products)) { + if (has(product.includedItems, item.id)) { toast({ - title: "Item is included in offer", - description: `Please remove it from the offer "${offerId}" before deleting.`, + title: "Item is included in product", + description: `Please remove it from the product "${productId}" before deleting.`, variant: "destructive", }); return "prevent-close"; diff --git a/apps/dashboard/src/components/data-table/payment-offer-table.tsx b/apps/dashboard/src/components/data-table/payment-product-table.tsx similarity index 74% rename from apps/dashboard/src/components/data-table/payment-offer-table.tsx rename to apps/dashboard/src/components/data-table/payment-product-table.tsx index 3e3f4c8b40..96a5245995 100644 --- a/apps/dashboard/src/components/data-table/payment-offer-table.tsx +++ b/apps/dashboard/src/components/data-table/payment-product-table.tsx @@ -1,20 +1,20 @@ 'use client'; import { useAdminApp } from "@/app/(main)/(protected)/projects/[projectId]/use-admin-app"; -import { OfferDialog } from "@/components/payments/offer-dialog"; +import { ProductDialog } from "@/components/payments/product-dialog"; import { branchPaymentsSchema } from "@stackframe/stack-shared/dist/config/schema"; import { ActionCell, ActionDialog, DataTable, DataTableColumnHeader, TextCell, toast } from "@stackframe/stack-ui"; import { ColumnDef } from "@tanstack/react-table"; import { useState } from "react"; import * as yup from "yup"; -type PaymentOffer = { +type PaymentProduct = { id: string, -} & yup.InferType["offers"][string]; +} & yup.InferType["products"][string]; -const columns: ColumnDef[] = [ +const columns: ColumnDef[] = [ { accessorKey: "id", - header: ({ column }) => , + header: ({ column }) => , cell: ({ row }) => {row.original.id}, enableSorting: false, }, @@ -44,14 +44,14 @@ const columns: ColumnDef[] = [ }, { id: "actions", - cell: ({ row }) => , + cell: ({ row }) => , } ]; -export function PaymentOfferTable({ offers }: { offers: Record["offers"][string]> }) { - const data: PaymentOffer[] = Object.entries(offers).map(([id, offer]) => ({ +export function PaymentProductTable({ products }: { products: Record["products"][string]> }) { + const data: PaymentProduct[] = Object.entries(products).map(([id, product]) => ({ id, - ...offer, + ...product, })); return ; } -function ActionsCell({ offer }: { offer: PaymentOffer }) { +function ActionsCell({ product }: { product: PaymentProduct }) { const [isEditOpen, setIsEditOpen] = useState(false); const [isDeleteOpen, setIsDeleteOpen] = useState(false); const stackAdminApp = useAdminApp(); @@ -85,25 +85,25 @@ function ActionsCell({ offer }: { offer: PaymentOffer }) { }, ]} /> - { - await project.updateConfig({ [`payments.offers.${offer.id}`]: null }); - toast({ title: "Offer deleted" }); + await project.updateConfig({ [`payments.products.${product.id}`]: null }); + toast({ title: "Product deleted" }); }, }} /> diff --git a/apps/dashboard/src/components/data-table/transaction-table.tsx b/apps/dashboard/src/components/data-table/transaction-table.tsx index ff38384228..b3f692a64e 100644 --- a/apps/dashboard/src/components/data-table/transaction-table.tsx +++ b/apps/dashboard/src/components/data-table/transaction-table.tsx @@ -60,11 +60,11 @@ const columns: ColumnDef[] = [ enableSorting: false, }, { - accessorKey: 'offer_or_item', - header: ({ column }) => , + accessorKey: 'product_or_item', + header: ({ column }) => , cell: ({ row }) => ( - {row.original.type === 'item_quantity_change' ? (row.original.item_id ?? '—') : (row.original.offer_display_name || '—')} + {row.original.type === 'item_quantity_change' ? (row.original.item_id ?? '—') : (row.original.product_display_name || '—')} ), enableSorting: false, @@ -145,7 +145,7 @@ export function TransactionTable() { customer_id: true, price: true, // Hide the rest by default; users can enable via View menu - offer_or_item: false, + product_or_item: false, quantity: false, test_mode: true, status: false, diff --git a/apps/dashboard/src/components/payments/create-checkout-dialog.tsx b/apps/dashboard/src/components/payments/create-checkout-dialog.tsx index 4385fcfbc6..f6aab07645 100644 --- a/apps/dashboard/src/components/payments/create-checkout-dialog.tsx +++ b/apps/dashboard/src/components/payments/create-checkout-dialog.tsx @@ -1,12 +1,12 @@ -import { Team, ServerUser } from "@stackframe/stack"; +import { useAdminApp } from "@/app/(main)/(protected)/projects/[projectId]/use-admin-app"; +import { ServerUser, Team } from "@stackframe/stack"; import { KnownErrors } from "@stackframe/stack-shared"; import { Result } from "@stackframe/stack-shared/dist/utils/results"; +import { ActionDialog, InlineCode, Typography, toast } from "@stackframe/stack-ui"; import { useState } from "react"; -import { ActionDialog, InlineCode, toast, Typography } from "@stackframe/stack-ui"; +import * as yup from "yup"; import { FormDialog } from "../form-dialog"; -import { useAdminApp } from "@/app/(main)/(protected)/projects/[projectId]/use-admin-app"; import { SelectField } from "../form-fields"; -import * as yup from "yup"; type Props = { open: boolean, @@ -27,19 +27,19 @@ export function CreateCheckoutDialog(props: Props) { const config = project.useConfig(); const [checkoutUrl, setCheckoutUrl] = useState(null); const customer = props.user ?? props.team; - const offers = config.payments.offers; - const shownOffers = Object.keys(offers).filter(id => offers[id].customerType === (props.user ? "user" : "team")); + const products = config.payments.products; + const shownProducts = Object.keys(products).filter(id => products[id].customerType === (props.user ? "user" : "team")); - const createCheckoutUrl = async (data: { offerId: string }) => { - const result = await Result.fromPromise(customer.createCheckoutUrl({ offerId: data.offerId })); + const createCheckoutUrl = async (data: { productId: string }) => { + const result = await Result.fromPromise(customer.createCheckoutUrl({ productId: data.productId })); if (result.status === "ok") { setCheckoutUrl(result.data); return; } - if (result.error instanceof KnownErrors.OfferDoesNotExist) { - toast({ title: "Offer with given offerId does not exist", variant: "destructive" }); - } else if (result.error instanceof KnownErrors.OfferCustomerTypeDoesNotMatch) { - toast({ title: "Customer type does not match expected type for this offer", variant: "destructive" }); + if (result.error instanceof KnownErrors.ProductDoesNotExist) { + toast({ title: "Product with given productId does not exist", variant: "destructive" }); + } else if (result.error instanceof KnownErrors.ProductCustomerTypeDoesNotMatch) { + toast({ title: "Customer type does not match expected type for this product", variant: "destructive" }); } else if (result.error instanceof KnownErrors.CustomerDoesNotExist) { toast({ title: "Customer with given customerId does not exist", variant: "destructive" }); } else { @@ -55,16 +55,16 @@ export function CreateCheckoutDialog(props: Props) { onOpenChange={props.onOpenChange} title="Create Checkout URL" formSchema={yup.object({ - offerId: yup.string().defined().label("Offer ID"), + productId: yup.string().defined().label("Product ID"), })} cancelButton okButton={{ label: "Create" }} onSubmit={values => createCheckoutUrl(values)} render={form => ({ value: id, label: id }))} + name="productId" + label="Product" + options={shownProducts.map(id => ({ value: id, label: id }))} />} /> (props: { USD: yup.string().defined().label("Price (USD)"), interval: dayIntervalSchema.optional().label("Interval"), }), - toFormValue: (id: string, value: OfferPrice) => typeof value === "string" ? value : ({ + toFormValue: (id: string, value: ProductPrice) => typeof value === "string" ? value : ({ id, USD: value.USD, interval: value.interval, diff --git a/apps/dashboard/src/components/payments/offer-dialog.tsx b/apps/dashboard/src/components/payments/product-dialog.tsx similarity index 80% rename from apps/dashboard/src/components/payments/offer-dialog.tsx rename to apps/dashboard/src/components/payments/product-dialog.tsx index 351bbb37f7..e0af474aaa 100644 --- a/apps/dashboard/src/components/payments/offer-dialog.tsx +++ b/apps/dashboard/src/components/payments/product-dialog.tsx @@ -5,9 +5,9 @@ import { CheckboxField, InputField, SelectField } from "@/components/form-fields import { IncludedItemEditorField } from "@/components/payments/included-item-editor"; import { PriceEditorField } from "@/components/payments/price-editor"; import { AdminProject } from "@stackframe/stack"; -import { offerSchema, priceOrIncludeByDefaultSchema, userSpecifiedIdSchema, yupRecord } from "@stackframe/stack-shared/dist/schema-fields"; +import { priceOrIncludeByDefaultSchema, productSchema, userSpecifiedIdSchema, yupRecord } from "@stackframe/stack-shared/dist/schema-fields"; import { has } from "@stackframe/stack-shared/dist/utils/objects"; -import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, FormLabel, FormItem, FormMessage, toast, FormField, Checkbox, FormControl, SimpleTooltip } from "@stackframe/stack-ui"; +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, Checkbox, FormControl, FormField, FormItem, FormLabel, FormMessage, SimpleTooltip, toast } from "@stackframe/stack-ui"; import * as yup from "yup"; type Props = { @@ -22,15 +22,15 @@ type Props = { mode: "edit", initial: { id: string, - value: yup.InferType, + value: yup.InferType, }, } ) -export function OfferDialog({ open, onOpenChange, project, mode, initial }: Props) { +export function ProductDialog({ open, onOpenChange, project, mode, initial }: Props) { const config = project.useConfig(); - const localOfferSchema = yup.object({ - offerId: userSpecifiedIdSchema("offerId").defined().label("Offer ID"), + const localProductSchema = yup.object({ + 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) => { @@ -55,19 +55,19 @@ export function OfferDialog({ open, onOpenChange, project, mode, initial }: Prop { if (mode === "create") { const config = await project.getConfig(); - if (has(config.payments.offers, values.offerId)) { - toast({ title: "An offer with this ID already exists", variant: "destructive" }); + if (has(config.payments.products, values.productId)) { + toast({ title: "An product with this ID already exists", variant: "destructive" }); return "prevent-close-and-prevent-reset"; } } @@ -79,11 +79,11 @@ export function OfferDialog({ open, onOpenChange, project, mode, initial }: Prop serverOnly: values.serverOnly, stackable: values.stackable, }; - await project.updateConfig({ [`payments.offers.${values.offerId}`]: payload }); + await project.updateConfig({ [`payments.products.${values.productId}`]: payload }); }} render={(form) => (
- +
- + Include by default diff --git a/apps/e2e/tests/backend/backend-helpers.ts b/apps/e2e/tests/backend/backend-helpers.ts index e97ea3dde9..9ef3f724d5 100644 --- a/apps/e2e/tests/backend/backend-helpers.ts +++ b/apps/e2e/tests/backend/backend-helpers.ts @@ -1449,9 +1449,9 @@ export namespace Payments { await Payments.setup(); await Project.updateConfig({ payments: { - offers: { - "test-offer": { - displayName: "Test Offer", + products: { + "test-product": { + displayName: "Test Product", customerType: "user", serverOnly: false, stackable: false, @@ -1474,7 +1474,7 @@ export namespace Payments { body: { customer_type: "user", customer_id: userId, - offer_id: "test-offer", + product_id: "test-product", }, }); expect(response.status).toBe(200); diff --git a/apps/e2e/tests/backend/endpoints/api/v1/internal/config.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/internal/config.test.ts index 6ed52f3aea..aaa7e6fe4f 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/internal/config.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/internal/config.test.ts @@ -374,7 +374,7 @@ it("returns an error when the oauth config is misconfigured", async ({ expect }) expect(invalidTypeResponse).toMatchInlineSnapshot(` NiceResponse { "status": 400, - "body": "[ERROR] auth.oauth.providers.invalid.type must be one of the following values: google, github, microsoft, spotify, facebook, discord, gitlab, bitbucket, linkedin, apple, x, twitch", + "body": "auth.oauth.providers.invalid.type must be one of the following values: google, github, microsoft, spotify, facebook, discord, gitlab, bitbucket, linkedin, apple, x, twitch", "headers": Headers {