Skip to content

Commit 8a77e07

Browse files
N2D4BilalG1
andauthored
Rename offer to product, offer group to product catalog (#914)
<!-- Make sure you've read the CONTRIBUTING.md guidelines: https://github.com/stack-auth/stack-auth/blob/dev/CONTRIBUTING.md --> <!-- RECURSEML_SUMMARY:START --> ## High-level PR Summary This PR implements a comprehensive renaming of "offer" to "product" and "offer group" to "product catalog" throughout the codebase. The changes include database migrations, schema updates, API compatibility layers, function renames, and updates to client and server implementations. Backwards compatibility is maintained through migration layers that handle requests using the old terminology, translating them to the new terminology before processing. The PR includes documentation of this approach in CLAUDE-KNOWLEDGE.md. This rename affects multiple parts of the system including the database schema, API endpoints, error types, and SDK interfaces. ⏱️ Estimated Review Time: 1-3 hours <details> <summary>💡 Review Order Suggestion</summary> | Order | File Path | |-------|-----------| | 1 | `apps/backend/prisma/migrations/20250923191615_rename_offers_to_products/migration.sql` | | 2 | `apps/backend/src/app/api/migrations/v2beta1/payments/purchases/offers-compat.ts` | | 3 | `apps/backend/src/app/api/migrations/v2beta1/payments/purchases/create-purchase-url/route.ts` | | 4 | `apps/backend/src/app/api/migrations/v2beta1/payments/purchases/validate-code/route.ts` | | 5 | `apps/backend/src/lib/payments.tsx` | | 6 | `.claude/CLAUDE-KNOWLEDGE.md` | | 7 | `packages/stack-shared/src/schema-fields.ts` | | 8 | `packages/stack-shared/src/known-errors.tsx` | | 9 | `packages/stack-shared/src/config/schema.ts` | | 10 | `packages/template/src/lib/stack-app/customers/index.ts` | | 11 | `packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts` | | 12 | `packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts` | </details> [![Need help? Join our Discord](https://img.shields.io/badge/Need%20help%3F%20Join%20our%20Discord-5865F2?style=plastic&logo=discord&logoColor=white)](https://discord.gg/n3SsVDAW6U) <!-- RECURSEML_SUMMARY:END --> <!-- ELLIPSIS_HIDDEN --> ---- > [!IMPORTANT] > Renames 'offer' to 'product' and 'offer group' to 'product catalog' across the codebase, updating database schema, API endpoints, and application logic for consistency and backward compatibility. > > - **Database**: > - Rename columns `offer` to `product` and `offerId` to `productId` in `OneTimePurchase` and `Subscription` tables in `migration.sql`. > - **API & Migrations**: > - Update API endpoints to accept `product_id`/`product_inline` instead of `offer_id`/`offer_inline`. > - Add `v2beta5` compatibility layer to map legacy `offer` fields to `product` equivalents. > - **Shared Schemas**: > - Rename `offerSchema` to `productSchema` and related schemas in `schema-fields.ts`. > - **Server Implementation**: > - Update `createCheckoutUrl` method in `server-app-impl.ts` to use `productId`/`InlineProduct`. > - **Tests**: > - Update tests to reflect renaming in `backend-helpers.ts` and other test files. > - **Miscellaneous**: > - Remove dummy data related to offers in `dummy-data.tsx`. > - Update documentation and comments to reflect terminology changes. > > <sup>This description was created by </sup>[<img alt="Ellipsis" src="https://img.shields.io/badge/Ellipsis-blue?color=175173">](https://www.ellipsis.dev?ref=stack-auth%2Fstack-auth&utm_source=github&utm_medium=referral)<sup> for e3227bc. You can [customize](https://app.ellipsis.dev/stack-auth/settings/summaries) this summary. It will automatically update as commits are pushed.</sup> ---- <!-- ELLIPSIS_HIDDEN --> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Backwards-compatibility: legacy offer_id/offer_inline requests are accepted, normalized, and routed to product-based handlers. * **Refactor** * Global rename from Offer/Group → Product/Catalog across UI, APIs, types, client/server interfaces, and error codes. * **Bug Fixes** * Responses, webhooks and UI consistently surface product_display_name and product-related metadata. * **Documentation** * Migration notes and docs updated to explain compatibility and parameter changes. * **Tests** * Unit and E2E suites updated to cover product/catalog flows. * **Chores** * Database schema migration, seed and config updates applied. <!-- end of auto-generated comment: release notes by coderabbit.ai --> <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Renames offers→products and groups→catalogs end-to-end (DB, APIs, schemas, UI, SDK, docs), adding v2beta5 compatibility to accept legacy offer fields while updating all internals. > > - **Backend/DB**: > - Prisma migration: rename `offer`/`offerId`→`product`/`productId` in `OneTimePurchase` and `Subscription`. > - Update Stripe webhook, purchase-session, and internal test-mode flows to use `product*` metadata/fields. > - **API & Migrations**: > - Latest endpoints now accept `product_id`/`product_inline`. > - Add `v2beta5` compat layer mapping legacy `offer_id`/`offer_inline` to product equivalents; responses alias conflicting products. > - **Shared Schemas/Errors/Config**: > - `offerSchema`→`productSchema`, `inlineOfferSchema`→`inlineProductSchema`, prices/types renamed. > - KnownErrors renamed (e.g., `PRODUCT_DOES_NOT_EXIST`). > - Config: `groups`→`catalogs`, defaults/migrations updated; improved override validation messages; ID regex loosened; formatter tweaks; add schema fuzzer tests. > - **Payments Lib**: > - Rename APIs and logic (`offers`→`products`, `groupId`→`catalogId`), subscription and item-quantity computation updated. > - **Dashboard/UI**: > - Routes, dialogs, editors, tables, and code samples switched to products/catalogs; removed offers dummy data. > - **SDK/Template**: > - Client/server `createCheckoutUrl` now uses `productId`/`InlineProduct`. > - **Tests/Docs/Utilities**: > - E2E and unit tests updated; add legacy (pre-rename) tests. > - Docs and knowledge base revised; minor script tweaks (recent-first, limits). > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit e6e20ec. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: BilalG1 <bg2002@gmail.com>
1 parent 0abaeea commit 8a77e07

77 files changed

Lines changed: 3214 additions & 1549 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.claude/CLAUDE-KNOWLEDGE.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,3 +366,6 @@ A: This happens when packages haven't been built yet. Run these commands in orde
366366
pnpm clean && pnpm i && pnpm codegen && pnpm build:packages
367367
```
368368
Then restart the dev server. This rebuilds all packages and generates the necessary TypeScript declarations.
369+
370+
## Q: How is backwards compatibility for the offer→product rename handled in the payments purchase APIs?
371+
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.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
-- AlterTable
2+
ALTER TABLE "OneTimePurchase"
3+
RENAME COLUMN "offer" TO "product";
4+
5+
-- AlterTable
6+
ALTER TABLE "OneTimePurchase"
7+
RENAME COLUMN "offerId" TO "productId";
8+
9+
-- AlterTable
10+
ALTER TABLE "Subscription"
11+
RENAME COLUMN "offer" TO "product";
12+
13+
-- AlterTable
14+
ALTER TABLE "Subscription"
15+
RENAME COLUMN "offerId" TO "productId";

apps/backend/prisma/schema.prisma

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -763,9 +763,9 @@ model Subscription {
763763
tenancyId String @db.Uuid
764764
customerId String
765765
customerType CustomerType
766-
offerId String?
766+
productId String?
767767
priceId String?
768-
offer Json
768+
product Json
769769
quantity Int @default(1)
770770
771771
stripeSubscriptionId String?
@@ -802,9 +802,9 @@ model OneTimePurchase {
802802
tenancyId String @db.Uuid
803803
customerId String
804804
customerType CustomerType
805-
offerId String?
805+
productId String?
806806
priceId String?
807-
offer Json
807+
product Json
808808
quantity Int
809809
stripePaymentIntentId String?
810810
createdAt DateTime @default(now())

apps/backend/prisma/seed.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -98,14 +98,14 @@ async function seed() {
9898
}
9999
},
100100
payments: {
101-
groups: {
101+
catalogs: {
102102
plans: {
103103
displayName: "Plans",
104104
}
105105
},
106-
offers: {
106+
products: {
107107
team: {
108-
groupId: "plans",
108+
catalogId: "plans",
109109
displayName: "Team",
110110
customerType: "team",
111111
serverOnly: false,
@@ -126,7 +126,7 @@ async function seed() {
126126
}
127127
},
128128
growth: {
129-
groupId: "plans",
129+
catalogId: "plans",
130130
displayName: "Growth",
131131
customerType: "team",
132132
serverOnly: false,
@@ -147,7 +147,7 @@ async function seed() {
147147
}
148148
},
149149
free: {
150-
groupId: "plans",
150+
catalogId: "plans",
151151
displayName: "Free",
152152
customerType: "team",
153153
serverOnly: false,
@@ -162,7 +162,7 @@ async function seed() {
162162
}
163163
},
164164
"extra-admins": {
165-
groupId: "plans",
165+
catalogId: "plans",
166166
displayName: "Extra Admins",
167167
customerType: "team",
168168
serverOnly: false,

apps/backend/scripts/verify-data-integrity.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ async function main() {
7676
const shouldSaveOutput = flags.includes("--save-output");
7777
const shouldVerifyOutput = flags.includes("--verify-output");
7878
const shouldSkipNeon = flags.includes("--skip-neon");
79+
const recentFirst = flags.includes("--recent-first");
7980

8081

8182
if (shouldSaveOutput) {
@@ -117,7 +118,9 @@ async function main() {
117118
displayName: true,
118119
description: true,
119120
},
120-
orderBy: {
121+
orderBy: recentFirst ? {
122+
updatedAt: "desc",
123+
} : {
121124
id: "asc",
122125
},
123126
});
@@ -126,7 +129,7 @@ async function main() {
126129
console.log(`Starting at project ${startAt}.`);
127130
}
128131

129-
const maxUsersPerProject = 10000;
132+
const maxUsersPerProject = 100;
130133

131134
const endAt = Math.min(startAt + count, projects.length);
132135
for (let i = startAt; i < endAt; i++) {
@@ -264,6 +267,7 @@ async function main() {
264267
console.log();
265268
console.log();
266269
}
270+
// eslint-disable-next-line no-restricted-syntax
267271
main().catch((...args) => {
268272
console.error();
269273
console.error();

apps/backend/src/app/api/latest/integrations/stripe/webhooks/route.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ async function processStripeWebhookEvent(event: Stripe.Event): Promise<void> {
5353
throw new StackAssertionError("Tenancy not found", { event });
5454
}
5555
const prisma = await getPrismaClientForTenancy(tenancy);
56-
const offer = JSON.parse(metadata.offer || "{}");
56+
const product = JSON.parse(metadata.product || "{}");
5757
const qty = Math.max(1, Number(metadata.purchaseQuantity || 1));
5858
const stripePaymentIntentId = event.data.object.id;
5959
if (!metadata.customerId || !metadata.customerType) {
@@ -73,17 +73,17 @@ async function processStripeWebhookEvent(event: Stripe.Event): Promise<void> {
7373
tenancyId: tenancy.id,
7474
customerId: metadata.customerId,
7575
customerType: typedToUppercase(metadata.customerType),
76-
offerId: metadata.offerId || null,
76+
productId: metadata.productId || null,
7777
priceId: metadata.priceId || null,
7878
stripePaymentIntentId,
79-
offer,
79+
product,
8080
quantity: qty,
8181
creationSource: "PURCHASE_PAGE",
8282
},
8383
update: {
84-
offerId: metadata.offerId || null,
84+
productId: metadata.productId || null,
8585
priceId: metadata.priceId || null,
86-
offer,
86+
product,
8787
quantity: qty,
8888
}
8989
});

apps/backend/src/app/api/latest/internal/config/override/crud.tsx

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import { getRenderedEnvironmentConfigQuery, overrideEnvironmentConfigOverride, validateEnvironmentConfigOverride } from "@/lib/config";
1+
import { getRenderedEnvironmentConfigQuery, overrideEnvironmentConfigOverride } from "@/lib/config";
22
import { globalPrismaClient, rawQuery } from "@/prisma-client";
33
import { createCrudHandlers } from "@/route-handlers/crud-handler";
4+
import { environmentConfigSchema, getConfigOverrideErrors, migrateConfigOverride } from "@stackframe/stack-shared/dist/config/schema";
45
import { configOverrideCrud } from "@stackframe/stack-shared/dist/interface/crud/config";
56
import { yupObject } from "@stackframe/stack-shared/dist/schema-fields";
67
import { StatusError } from "@stackframe/stack-shared/dist/utils/errors";
@@ -20,14 +21,10 @@ export const configOverridesCrudHandlers = createLazyProxy(() => createCrudHandl
2021
throw e;
2122
}
2223

23-
const validationResult = await validateEnvironmentConfigOverride({
24-
environmentConfigOverride: parsedConfig,
25-
branchId: auth.tenancy.branchId,
26-
projectId: auth.tenancy.project.id,
27-
});
28-
29-
if (validationResult.status === "error") {
30-
throw new StatusError(StatusError.BadRequest, validationResult.error);
24+
// TODO instead of doing this check here, we should change overrideEnvironmentConfigOverride to return the errors from its ensureNoConfigOverrideErrors call
25+
const overrideError = await getConfigOverrideErrors(environmentConfigSchema, migrateConfigOverride("environment", parsedConfig));
26+
if (overrideError.status === "error") {
27+
throw new StatusError(StatusError.BadRequest, overrideError.error);
3128
}
3229

3330
await overrideEnvironmentConfigOverride({

apps/backend/src/app/api/latest/internal/payments/test-mode-purchase-session/route.tsx

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ export const POST = createSmartRouteHandler({
3737
}
3838
const prisma = await getPrismaClientForTenancy(auth.tenancy);
3939

40-
const { selectedPrice, conflictingGroupSubscriptions } = await validatePurchaseSession({
40+
const { selectedPrice, conflictingCatalogSubscriptions } = await validatePurchaseSession({
4141
prisma,
4242
tenancy: auth.tenancy,
4343
codeData: data,
@@ -53,18 +53,18 @@ export const POST = createSmartRouteHandler({
5353
data: {
5454
tenancyId: auth.tenancy.id,
5555
customerId: data.customerId,
56-
customerType: typedToUppercase(data.offer.customerType),
57-
offerId: data.offerId,
56+
customerType: typedToUppercase(data.product.customerType),
57+
productId: data.productId,
5858
priceId: price_id,
59-
offer: data.offer,
59+
product: data.product,
6060
quantity,
6161
creationSource: "TEST_MODE",
6262
},
6363
});
6464
} else {
6565
// Cancel conflicting subscriptions for TEST_MODE as well, then create new TEST_MODE subscription
66-
if (conflictingGroupSubscriptions.length > 0) {
67-
const conflicting = conflictingGroupSubscriptions[0];
66+
if (conflictingCatalogSubscriptions.length > 0) {
67+
const conflicting = conflictingCatalogSubscriptions[0];
6868
if (conflicting.stripeSubscriptionId) {
6969
const stripe = await getStripeForAccount({ tenancy: auth.tenancy });
7070
await stripe.subscriptions.cancel(conflicting.stripeSubscriptionId);
@@ -85,11 +85,11 @@ export const POST = createSmartRouteHandler({
8585
data: {
8686
tenancyId: auth.tenancy.id,
8787
customerId: data.customerId,
88-
customerType: typedToUppercase(data.offer.customerType),
88+
customerType: typedToUppercase(data.product.customerType),
8989
status: "active",
90-
offerId: data.offerId,
90+
productId: data.productId,
9191
priceId: price_id,
92-
offer: data.offer,
92+
product: data.product,
9393
quantity,
9494
currentPeriodStart: new Date(),
9595
currentPeriodEnd: addInterval(new Date(), selectedPrice.interval!),

apps/backend/src/app/api/latest/internal/payments/transactions/route.tsx

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,24 +8,24 @@ import { typedToLowercase, typedToUppercase } from "@stackframe/stack-shared/dis
88

99

1010
type SelectedPrice = NonNullable<AdminTransaction['price']>;
11-
type OfferWithPrices = {
11+
type ProductWithPrices = {
1212
displayName?: string,
1313
prices?: Record<string, SelectedPrice & { serverOnly?: unknown, freeTrial?: unknown }> | "include-by-default",
1414
} | null | undefined;
1515

16-
function resolveSelectedPriceFromOffer(offer: OfferWithPrices, priceId?: string | null): SelectedPrice | null {
17-
if (!offer) return null;
16+
function resolveSelectedPriceFromProduct(product: ProductWithPrices, priceId?: string | null): SelectedPrice | null {
17+
if (!product) return null;
1818
if (!priceId) return null;
19-
const prices = offer.prices;
19+
const prices = product.prices;
2020
if (!prices || prices === "include-by-default") return null;
2121
const selected = prices[priceId as keyof typeof prices] as (SelectedPrice & { serverOnly?: unknown, freeTrial?: unknown }) | undefined;
2222
if (!selected) return null;
2323
const { serverOnly: _serverOnly, freeTrial: _freeTrial, ...rest } = selected as any;
2424
return rest as SelectedPrice;
2525
}
2626

27-
function getOfferDisplayName(offer: OfferWithPrices): string | null {
28-
return offer?.displayName ?? null;
27+
function getProductDisplayName(product: ProductWithPrices): string | null {
28+
return product?.displayName ?? null;
2929
}
3030

3131

@@ -137,8 +137,8 @@ export const GET = createSmartRouteHandler({
137137
customer_id: s.customerId,
138138
quantity: s.quantity,
139139
test_mode: s.creationSource === 'TEST_MODE',
140-
offer_display_name: getOfferDisplayName(s.offer as OfferWithPrices),
141-
price: resolveSelectedPriceFromOffer(s.offer as OfferWithPrices, s.priceId ?? null),
140+
product_display_name: getProductDisplayName(s.product as ProductWithPrices),
141+
price: resolveSelectedPriceFromProduct(s.product as ProductWithPrices, s.priceId ?? null),
142142
status: s.status,
143143
}));
144144

@@ -153,7 +153,7 @@ export const GET = createSmartRouteHandler({
153153
customer_id: i.customerId,
154154
quantity: i.quantity,
155155
test_mode: false,
156-
offer_display_name: null,
156+
product_display_name: null,
157157
price: null,
158158
status: null,
159159
item_id: i.itemId,
@@ -170,8 +170,8 @@ export const GET = createSmartRouteHandler({
170170
customer_id: o.customerId,
171171
quantity: o.quantity,
172172
test_mode: o.creationSource === 'TEST_MODE',
173-
offer_display_name: getOfferDisplayName(o.offer as OfferWithPrices),
174-
price: resolveSelectedPriceFromOffer(o.offer as OfferWithPrices, o.priceId ?? null),
173+
product_display_name: getProductDisplayName(o.product as ProductWithPrices),
174+
price: resolveSelectedPriceFromProduct(o.product as ProductWithPrices, o.priceId ?? null),
175175
status: null,
176176
}));
177177

apps/backend/src/app/api/latest/payments/purchases/create-purchase-url/route.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
import { ensureOfferIdOrInlineOffer } from "@/lib/payments";
1+
import { ensureProductIdOrInlineProduct } from "@/lib/payments";
22
import { getStripeForAccount } from "@/lib/stripe";
33
import { globalPrismaClient } from "@/prisma-client";
44
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
55
import { CustomerType } from "@prisma/client";
66
import { KnownErrors } from "@stackframe/stack-shared/dist/known-errors";
7-
import { adaptSchema, clientOrHigherAuthTypeSchema, inlineOfferSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
7+
import { adaptSchema, clientOrHigherAuthTypeSchema, inlineProductSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
88
import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
99
import { throwErr } from "@stackframe/stack-shared/dist/utils/errors";
1010
import { purchaseUrlVerificationCodeHandler } from "../verification-code-handler";
@@ -22,8 +22,8 @@ export const POST = createSmartRouteHandler({
2222
body: yupObject({
2323
customer_type: yupString().oneOf(["user", "team", "custom"]).defined(),
2424
customer_id: yupString().defined(),
25-
offer_id: yupString().optional(),
26-
offer_inline: inlineOfferSchema.optional(),
25+
product_id: yupString().optional(),
26+
product_inline: inlineProductSchema.optional(),
2727
}),
2828
}),
2929
response: yupObject({
@@ -36,10 +36,10 @@ export const POST = createSmartRouteHandler({
3636
handler: async (req) => {
3737
const { tenancy } = req.auth;
3838
const stripe = await getStripeForAccount({ tenancy });
39-
const offerConfig = await ensureOfferIdOrInlineOffer(tenancy, req.auth.type, req.body.offer_id, req.body.offer_inline);
40-
const customerType = offerConfig.customerType;
39+
const productConfig = await ensureProductIdOrInlineProduct(tenancy, req.auth.type, req.body.product_id, req.body.product_inline);
40+
const customerType = productConfig.customerType;
4141
if (req.body.customer_type !== customerType) {
42-
throw new KnownErrors.OfferCustomerTypeDoesNotMatch(req.body.offer_id, req.body.customer_id, customerType, req.body.customer_type);
42+
throw new KnownErrors.ProductCustomerTypeDoesNotMatch(req.body.product_id, req.body.customer_id, customerType, req.body.customer_type);
4343
}
4444

4545
const stripeCustomerSearch = await stripe.customers.search({
@@ -66,8 +66,8 @@ export const POST = createSmartRouteHandler({
6666
data: {
6767
tenancyId: tenancy.id,
6868
customerId: req.body.customer_id,
69-
offerId: req.body.offer_id,
70-
offer: offerConfig,
69+
productId: req.body.product_id,
70+
product: productConfig,
7171
stripeCustomerId: stripeCustomer.id,
7272
stripeAccountId: project?.stripeAccountId ?? throwErr("Stripe account not configured"),
7373
},

0 commit comments

Comments
 (0)