-
Notifications
You must be signed in to change notification settings - Fork 469
Rename offer to product, offer group to product catalog #914
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Warning Rate limit exceeded@N2D4 has exceeded the limit for the number of commits or files that can be reviewed per hour. Please wait 15 minutes and 40 seconds before requesting another review. ⌛ How to resolve this issue?After the wait time has elapsed, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?Co 8000 deRabbit enforces hourly rate limits for each developer per organization. Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout. Please see our FAQ for further information. 📒 Files selected for processing (1)
Note Other AI code review bot(s) detectedCodeRabbit has detected other AI code review bot(s) in this pull request and will avoid duplicating their findings in the review comments. This may lead to a less comprehensive review. WalkthroughRenames "offer(s)" → "product(s)" across DB schema, backend payments routes/handlers, shared schemas/errors/interfaces, dashboard UI, clients, templates, tests and docs; adds v2beta5 migration wrappers that accept legacy Changes
Sequence Diagram(s)sequenceDiagram
autonumber
participant Client
participant V2Wrapper as v2beta5_wrapper
participant Latest as latest_create-purchase-url
Client->>V2Wrapper: POST { offer_id?/offer_inline?, product_*? }
V2Wrapper->>V2Wrapper: normalizePurchaseBody (map offer_* → product_*)
V2Wrapper->>Latest: POST { product_id/product_inline }
Latest->>Latest: validate input, create verification code
Latest-->>Client: { purchase_url }
note right of V2Wrapper: Legacy offer fields accepted and translated
sequenceDiagram
autonumber
participant Client
participant Purchase as purchase-session
participant Stripe
participant DB
Client->>Purchase: POST { productId/product, priceId, quantity }
Purchase->>Stripe: create/update session (metadata: productId/product)
Stripe-->>Purchase: session/subscription
Purchase->>DB: upsert OneTimePurchase/Subscription with productId/product
DB-->>Purchase: ok
Purchase-->>Client: { client_secret or id }
note right of Purchase: Conflict checks use catalogs/products
Estimated code review effort🎯 5 (Critical) | ⏱️ ~120 minutes Possibly related PRs
Poem
Pre-merge checks and finishing touches❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Review by RecurseML
🔍 Review performed on 7a0bf86..ccc90ea
Severity | Location | Issue | Delete |
---|---|---|---|
apps/e2e/tests/backend/endpoints/api/v1/old-name/purchase-session.test.ts:4 | Missing runAsynchronously wrapper for async function | ||
apps/e2e/tests/backend/endpoints/api/v1/old-name/items.test.ts:6 | URL string concatenation instead of proper URL construction | ||
apps/e2e/tests/backend/endpoints/api/v1/old-name/items.test.ts:18 | URL string with IDs embedded instead of proper URL construction | ||
apps/e2e/tests/backend/endpoints/api/v1/old-name/create-purchase-url.test.ts:33 | Inconsistent naming convention: using camelCase instead of snake_case for API-related configuration property | ||
apps/e2e/tests/backend/endpoints/api/v1/old-name/create-purchase-url.test.ts:34 | Inconsistent naming convention: using camelCase instead of snake_case for API-related configuration property | ||
apps/e2e/tests/backend/endpoints/api/v1/old-name/create-purchase-url.test.ts:42 | Inconsistent naming convention: using camelCase instead of snake_case for API-related configuration property | ||
apps/e2e/tests/backend/endpoints/api/v1/old-name/create-purchase-url.test.ts:8 | URL string concatenation instead of using proper URL construction utilities | ||
apps/e2e/tests/backend/endpoints/api/v1/old-name/create-purchase-url.test.ts:100 | URL string concatenation instead of using proper URL construction utilities | ||
apps/e2e/tests/backend/endpoints/api/v1/old-name/create-purchase-url.test.ts:177 | URL string concatenation instead of using proper URL construction utilities | ||
apps/e2e/tests/backend/endpoints/api/v1/old-name/create-purchase-url.test.ts:305 | URL string concatenation instead of using proper URL construction utilities | ||
apps/e2e/tests/backend/endpoints/api/v1/old-name/validate-code.test.ts:31 | URL concatenation as string instead of using proper URL construction utilities | ||
apps/e2e/tests/backend/endpoints/api/v1/old-name/validate-code.test.ts:102 | URL concatenation as string instead of using proper URL construction utilities | ||
apps/e2e/tests/backend/endpoints/api/v1/old-name/validate-code.test.ts:114 | URL concatenation as string instead of using proper URL construction utilities | ||
apps/e2e/tests/backend/endpoints/api/v1/old-name/validate-code.test.ts:127 | URL concatenation as string instead of using proper URL construction utilities | ||
apps/e2e/tests/backend/endpoints/api/v1/old-name/validate-code.test.ts:201 | URL concatenation as string instead of using proper URL construction utilities |
✅ Files analyzed, no issues (1)
• apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offers/page-client-catalogs-view.tsx
⏭️ Files skipped (59)
Locations |
---|
.claude/CLAUDE-KNOWLEDGE.md |
apps/backend/prisma/migrations/20250923191615_rename_offers_to_products/migration.sql |
apps/backend/prisma/schema.prisma |
apps/backend/prisma/seed.ts |
apps/backend/src/app/api/latest/integrations/stripe/webhooks/route.tsx |
apps/backend/src/app/api/latest/internal/payments/test-mode-purchase-session/route.tsx |
apps/backend/src/app/api/latest/internal/payments/transactions/route.tsx |
apps/backend/src/app/api/latest/payments/purchases/create-purchase-url/route.ts |
apps/backend/src/app/api/latest/payments/purchases/purchase-session/route.tsx |
apps/backend/src/app/api/latest/payments/purchases/validate-code/route.ts |
apps/backend/src/app/api/latest/payments/purchases/verification-code-handler.tsx |
apps/backend/src/app/api/migrations/README.md |
apps/backend/src/app/api/migrations/v2beta1/payments/purchases/create-purchase-url/route.ts |
apps/backend/src/app/api/migrations/v2beta1/payments/purchases/offers-compat.ts |
apps/backend/src/app/api/migrations/v2beta1/payments/purchases/validate-code/route.ts |
apps/backend/src/lib/payments.test.tsx |
apps/backend/src/lib/payments.tsx |
apps/backend/src/lib/stripe.tsx |
apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/page-client.tsx |
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offers/create-group-dialog.tsx |
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offers/dummy-data.tsx |
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offers/included-item-dialog.tsx |
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offers/offer-dialog.tsx |
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offers/page-client-list-view.tsx |
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offers/price-dialog.tsx |
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/page.tsx |
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx |
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/workflows/workflow-list.tsx |
apps/dashboard/src/app/(main)/purchase/[code]/page-client.tsx |
apps/dashboard/src/components/data-table/payment-item-table.tsx |
apps/dashboard/src/components/data-table/payment-offer-table.tsx |
apps/dashboard/src/components/data-table/transaction-table.tsx |
apps/dashboard/src/components/payments/create-checkout-dialog.tsx |
apps/dashboard/src/components/payments/offer-dialog.tsx |
apps/dashboard/src/components/payments/price-editor.tsx |
apps/e2e/tests/backend/backend-helpers.ts |
apps/e2e/tests/backend/endpoints/api/v1/internal/transactions.test.ts |
apps/e2e/tests/backend/endpoints/api/v1/payments/create-purchase-url.test.ts |
apps/e2e/tests/backend/endpoints/api/v1/payments/purchase-session.test.ts |
apps/e2e/tests/backend/endpoints/api/v1/payments/validate-code.test.ts |
apps/e2e/tests/backend/endpoints/api/v1/stripe-webhooks.test.ts |
docs/src/app/api/chat/route.ts |
docs/templates-api/overview.mdx |
docs/templates/concepts/user-onboarding.mdx |
docs/templates/customization/internationalization.mdx |
docs/templates/customization/page-examples/sign-in.mdx |
docs/templates/customization/page-examples/sign-up.mdx |
docs/templates/others/self-host.mdx |
docs/templates/overview.mdx |
docs/templates/rest-api/overview.mdx |
examples/e-commerce/src/app/page.tsx |
packages/stack-shared/src/config/schema.ts |
packages/stack-shared/src/interface/client-interface.ts |
packages/stack-shared/src/interface/crud/transactions.ts |
packages/stack-shared/src/known-errors.tsx |
packages/stack-shared/src/schema-fields.ts |
packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts |
packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts |
packages/template/src/lib/stack-app/customers/index.ts |
import { it } from "../../../../../helpers"; | ||
import { Auth, Payments, Project, User, niceBackendFetch } from "../../../../backend-helpers"; | ||
|
||
it("should error on invalid code", async ({ expect }) => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Async function is not wrapped with runAsynchronously helper as required by the code pattern rule. According to the rule, bare async functions should never be used - instead use runAsynchronously(asyncFunction)
to properly handle async errors.
🔍 This comment matches your code_patterns.mdc
rule.
it("should error on invalid code", async ({ expect }) => { | |
it("should error on invalid code", runAsynchronously(async ({ expect }) => { |
React with 👍 to tell me that this comment was useful, or 👎 if not (and I'll stop posting more comments like this in the future)
import { Auth, InternalProjectKeys, Project, User, backendContext, createMailbox, niceBackendFetch } from "../../../../backend-helpers"; | ||
|
||
async function updateConfig(config: any) { | ||
const response = await niceBackendFetch(`/api/latest/internal/config/override`, { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The code is concatenating URLs as strings with backticks, which violates the rule that states 'Don't concatenate URLs as strings - avoid patterns like /users/${userId}
'. URL construction should use a proper URL building utility instead of string concatenation to avoid potential issues with URL encoding and path construction.
🔍 This comment matches your code_patterns.mdc
rule.
const response = await niceBackendFetch(`/api/latest/internal/config/override`, { | |
const response = await niceBackendFetch(new URL('/api/latest/internal/config/override', backendContext.get().baseUrl).toString(), { |
React with 👍 to tell me that this comment was useful, or 👎 if not (and I'll stop posting more comments like this in the future)
it("should not be able to get item without access type", async ({ expect }) => { | ||
await Project.createAndSwitch(); | ||
|
||
const response = await niceBackendFetch("/api/latest/payments/items/user/user-123/test-item"); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The code directly includes IDs in URL paths via string concatenation. According to the rule 'Don't concatenate URLs as strings - avoid patterns like /users/${userId}
', this is a violation. Even though this is a hardcoded string, it demonstrates and reinforces a pattern that should be avoided. URL paths with parameters should be constructed using proper URL utilities.
🔍 This comment matches your code_patterns.mdc
rule.
React with 👍 to tell me that this comment was useful, or 👎 if not (and I'll stop posting more comments like this in the future)
offers: { | ||
"test-offer": { | ||
displayName: "Test Offer", | ||
customerType: "user", |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This line uses camelCase ('customerType') for a configuration property that corresponds to an API field. According to the naming convention rule, REST API parameters should use snake_case. This creates an inconsistency with line 185 where the same concept is represented as 'customer_type' in snake_case in the API request body. Since this is used to configure an API-related object, it should use snake_case as 'customer_type' for consistency.
🔍 This comment matches your naming.mdc
rule.
customerType: "user", | |
customer_type: "user", |
React with 👍 to tell me that this comment was useful, or 👎 if not (and I'll stop posting more comments like this in the future)
"test-offer": { | ||
displayName: "Test Offer", | ||
customerType: "user", | ||
serverOnly: false, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This line uses camelCase ('serverOnly') for a configuration property that corresponds to an API field. According to the naming convention rule, REST API parameters should use snake_case. This creates an inconsistency with line 186 where the same concept is represented as 'server_only' in snake_case in the API request body. Since this is used to configure an API-related object, it should use snake_case as 'server_only' for consistency.
🔍 This comment matches your naming.mdc
rule.
serverOnly: false, | |
server_only: false, |
React with 👍 to tell me that this comment was useful, or 👎 if not (and I'll stop posting more comments like this in the future)
|
||
it("should allow valid code and return offer data", async ({ expect }) => { | ||
const { code } = await Payments.createPurchaseUrlAndGetCode(); | ||
const validateResponse = await niceBackendFetch("/api/latest/payments/purchases/validate-code", { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The URL path is directly concatenated as a string rather than using proper URL construction utilities. This violates the code pattern rule that states: "Don't concatenate URLs as strings - avoid patterns like /users/${userId}
". A URL construction utility should be used instead for safety and consistency.
🔍 This comment matches your code_patterns.mdc
rule.
const validateResponse = await niceBackendFetch("/api/latest/payments/purchases/validate-code", { | |
const validateResponse = await niceBackendFetch(new URL("/api/latest/payments/purchases/validate-code", window.origin).toString(), { |
React with 👍 to tell me that this comment was useful, or 👎 if not (and I'll stop posting more comments like this in the future)
const code1 = (createUrlRes1.body as { url: string }).url.match(/\/purchase\/([a-z0-9-_]+)/)?.[1]; | ||
expect(code1).toBeDefined(); | ||
|
||
const testModeRes = await niceBackendFetch("/api/latest/internal/payments/test-mode-purchase-session", { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The code directly concatenates the URL path as a string, which violates the code pattern rule that states: "Don't concatenate URLs as strings - avoid patterns like /users/${userId}
". A proper URL construction utility should be used instead for consistency and to avoid potential errors.
🔍 This comment matches your code_patterns.mdc
rule.
React with 👍 to tell me that this comment was useful, or 👎 if not (and I'll stop posting more comments like this in the future)
expect(testModeRes.status).toBe(200); | ||
|
||
// Create a second code for the same offer and validate; should report already_bought_non_stackable | ||
const createUrlRes2 = await niceBackendFetch("/api/latest/payments/purchases/create-purchase-url", { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The code directly concatenates the URL path as a string, which violates the code pattern rule that states: "Don't concatenate URLs as strings - avoid patterns like /users/${userId}
". A proper URL construction utility should be used instead for consistency and to avoid potential errors.
🔍 This comment matches your code_patterns.mdc
rule.
const createUrlRes2 = await niceBackendFetch("/api/latest/payments/purchases/create-purchase-url", { | |
const createUrlRes2 = await niceBackendFetch(new URL("/api/latest/payments/purchases/create-purchase-url", import.meta.url).toString(), { |
React with 👍 to tell me that this comment was useful, or 👎 if not (and I'll stop posting more comments like this in the future)
const code2 = (createUrlRes2.body as { url: string }).url.match(/\/purchase\/([a-z0-9-_]+)/)?.[1]; | ||
expect(code2).toBeDefined(); | ||
|
||
const validateResponse = await niceBackendFetch("/api/latest/payments/purchases/validate-code", { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The code directly concatenates the URL path as a string, which violates the code pattern rule that states: "Don't concatenate URLs as strings - avoid patterns like /users/${userId}
". A proper URL construction utility should be used instead for consistency and to avoid potential errors.
🔍 This comment matches your code_patterns.mdc
rule.
const validateResponse = await niceBackendFetch("/api/latest/payments/purchases/validate-code", { | |
const validateResponse = await niceBackendFetch(new URL("/api/latest/payments/purchases/validate-code", import.meta.url).toString(), { |
React with 👍 to tell me that this comment was useful, or 👎 if not (and I'll stop posting more comments like this in the future)
const codeA = (resUrlA.body as { url: string }).url.match(/\/purchase\/([a-z0-9-_]+)/)?.[1]; | ||
expect(codeA).toBeDefined(); | ||
|
||
const testModeRes = await niceBackendFetch("/api/latest/internal/payments/test-mode-purchase-session", { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The code directly concatenates the URL path as a string, which violates the code pattern rule that states: "Don't concatenate URLs as strings - avoid patterns like /users/${userId}
". A proper URL construction utility should be used instead for consistency and to avoid potential errors.
🔍 This comment matches your code_patterns.mdc
rule.
React with 👍 to tell me that this comment was useful, or 👎 if not (and I'll stop posting more comments like this in the future)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull Request Overview
This PR implements a comprehensive rename from "offer" to "product" and "offer group" to "product catalog" across the Stack Auth codebase. This is a significant refactoring that affects multiple layers including database schema, API interfaces, TypeScript types, React components, documentation, and test files.
Key Changes:
- Renamed database columns and JSON fields from
offer*
toproduct*
- Updated API endpoints to use
product_id
andproduct_inline
instead ofoffer_id
andoffer_inline
- Refactored TypeScript schemas and types throughout the codebase
- Modified React components and UI elements to reflect the new terminology
- Added backwards compatibility layer for API migrations to support legacy
offer_*
parameters
Reviewed Changes
Copilot reviewed 64 out of 64 changed files in this pull request and generated 10 comments.
Show a summary per file
:
File | Description |
---|---|
packages/stack-shared/src/schema-fields.ts | Updated schema definitions from of 9E88 fer to product terminology |
packages/stack-shared/src/known-errors.tsx | Renamed error classes and messages for product terminology |
packages/template/src/lib/stack-app/customers/index.ts | Updated TypeScript types and interfaces |
apps/backend/prisma/schema.prisma | Updated database schema column names |
apps/backend/src/lib/payments.tsx | Core payment logic refactored for product terminology |
apps/dashboard/src/components/payments/ | UI components updated for product terminology |
apps/e2e/tests/ | Test files updated to use product terminology |
docs/templates/ | Documentation updated to reflect new terminology |
Tip: Customize your code reviews with copilot-instructions.md. Create the file or learn how to get started.
<div key={shop[0].id} className="card"> | ||
<h2>{shop[1].displayName}</h2> | ||
Offered by {shop[0].displayName ?? shop[0].primaryEmail}<br /> | ||
Producted by {shop[0].displayName ?? shop[0].primaryEmail}<br /> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
"Producted" should be "Produced" - this appears to be a typo where "offer" was incorrectly replaced with "product" as a verb form.
Producted by {shop[0].displayName ?? shop[0].primaryEmail}<br /> | |
Produced by {shop[0].displayName ?? shop[0].primaryEmail}<br /> |
Copilot uses AI. Check for mistakes.
--- | ||
|
||
Stack offers a REST API for backends & frontends of any programming language or framework. This API is used to authenticate users, manage user data, and more. | ||
Stack products a REST API for backends & frontends of any programming language or framework. This API is used to authenticate users, manage user data, and more. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
"Stack products a REST API" should be "Stack provides a REST API" - this is a grammatical error where "offers" was incorrectly replaced with "products" as a verb.
Stack products a REST API for backends & frontends of any programming language or framework. This API is used to authenticate users, manage user data, and more. | |
Stack provides a REST API for backends & frontends of any programming language or framework. This API is used to authenticate users, manage user data, and more. |
Copilot uses AI. Check for mistakes.
That's why we built Stack Auth. Integrating secure authentication into your app should take **5 minutes**, not 5 days. | ||
|
||
At the core of Stack Auth are deep integrations into frontend and backend frameworks. We offer the best developer experience with Next.js. Instead of providing mediocre support for numerous frameworks, we focused on making a few integrations excellent before adding new ones. We also offer a cross-compatible REST API as a fallback. | ||
At the core of Stack Auth are deep integrations into frontend and backend frameworks. We product the best developer experience with Next.js. Instead of providing mediocre support for numerous frameworks, we focused on making a few integrations excellent before adding new ones. We also product a cross-compatible REST API as a fallback. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
"We product the best developer experience" and "We also product a cross-compatible REST API" should be "We provide the best developer experience" and "We also provide a cross-compatible REST API" - these are grammatical errors where "offer" was incorrectly replaced with "product" as a verb.
At the core of Stack Auth are deep integrations into frontend and backend frameworks. We product the best developer experience with Next.js. Instead of providing mediocre support for numerous frameworks, we focused on making a few integrations excellent before adding new ones. We also product a cross-compatible REST API as a fallback. | |
At the core of Stack Auth are deep integrations into frontend and backend frameworks. We provide the best developer experience with Next.js. Instead of providing mediocre support for numerous frameworks, we focused on making a few integrations excellent before adding new ones. We also provide a cross-compatible REST API as a fallback. |
Copilot uses AI. Check for mistakes.
- **Client SDK**: An SDK used to connect your app to the Stack Auth API backend, wrapping API calls and providing easy-to-use interfaces. More details [here](../getting-started/setup.mdx). | ||
- **Postgres database**: Used to store all user data. We use [Prisma](https://prisma.io) as the ORM and manage the database schema migrations. | ||
- **Svix**: Used to send webhooks. Svix is open-source and can be self-hosted, but also offers a cloud hosted solution. More on Svix [here](https://svix.com) | ||
- **Svix**: Used to send webhooks. Svix is open-source and can be self-hosted, but also products a cloud hosted solution. More on Svix [here](https://svix.com) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
"but also products a cloud hosted solution" should be "but also provides a cloud hosted solution" - this is a grammatical error where "offers" was incorrectly replaced with "products" as a verb.
- **Svix**: Used to send webhooks. Svix is open-source and can be self-hosted, but also products a cloud hosted solution. More on Svix [here](https://svix.com) | |
- **Svix**: Used to send webhooks. Svix is open-source and can be self-hosted, but also provides a cloud hosted solution. More on Svix [here](https://svix.com) |
Copilot uses AI. Check for mistakes.
`OAuthGroup`: A component that displays a list of available OAuth provider sign-up buttons. The available provider list is automatically fetched from the server based on your project configuration. | ||
|
||
`OAuthButton`: A component that renders a single OAuth sign-up button for a specific provider. Use this when you only want to offer specific OAuth providers. | ||
`OAuthButton`: A component that renders a single OAuth sign-up button for a specific provider. Use this when you only want to product specific OAuth providers. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
"when you only want to product specific OAuth providers" should be "when you only want to offer specific OAuth providers" - this is a grammatical error where "offer" was incorrectly replaced with "product" as a verb.
`OAuthButton`: A component that renders a single OAuth sign-up button for a specific provider. Use this when you only want to product specific OAuth providers. | |
`OAuthButton`: A component that renders a single OAuth sign-up button for a specific provider. Use this when you only want to offer specific OAuth providers. |
Copilot uses AI. Check for mistakes.
`OAuthGroup`: A component that displays a list of available OAuth provider sign-in buttons. The available provider list is automatically fetched from the server based on your project configuration. | ||
|
||
`OAuthButton`: A component that renders a single OAuth sign-in button for a specific provider. Use this when you only want to offer specific OAuth providers. | ||
`OAuthButton`: A component that renders a single OAuth sign-in button for a specific provider. Use this when you only want to product specific OAuth providers. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
"when you only want to product specific OAuth providers" should be "when you only want to offer specific OAuth providers" - this is a grammatical error where "offer" was incorrectly replaced with "product" as a verb.
`OAuthButton`: A component that renders a single OAuth sign-in button for a specific provider. Use this when you only want to product specific OAuth providers. | |
`OAuthButton`: A component that renders a single OAuth sign-in button for a specific provider. Use this when you only want to offer specific OAuth providers. |
Copilot uses AI. Check for mistakes.
--- | ||
|
||
Internationalization (i18n) allows your application to support multiple languages, making it accessible to users worldwide. Stack Auth provides built-in internationalization support for its components, enabling you to offer a localized authentication experience with minimal effort. | ||
Internationalization (i18n) allows your application to support multiple languages, making it accessible to users worldwide. Stack Auth provides built-in internationalization support for its components, enabling you to product a localized authentication experience with minimal effort. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
"enabling you to product a localized authentication experience" should be "enabling you to provide a localized authentication experience" - this is a grammatical error where "offer" was incorrectly replaced with "product" as a verb.
Internationalization (i18n) allows your application to support multiple languages, making it accessible to users worldwide. Stack Auth provides built-in internationalization support for its components, enabling you to product a localized authentication experience with minimal effort. | |
Internationalization (i18n) allows your application to support multiple languages, making it accessible to users worldwide. Stack Auth provides built-in internationalization support for its components, enabling you to provide a localized authentication experience with minimal effort. |
Copilot uses AI. Check for mistakes.
|
||
<Info> | ||
While the above implementation offers a basic onboarding process, users can still skip onboarding by directly sending an API request to update the `clientMetadata.onboarded` flag. If you want to ensure that onboarding cannot be bypassed on the API level, you should create a server endpoint to validate and store the data, then save the `onboarded` flag in the `clientReadonlyMetadata` on the server side after validation. | ||
While the above implementation products a basic onboarding process, users can still skip onboarding by directly sending an API request to update the `clientMetadata.onboarded` flag. If you want to ensure that onboarding cannot be bypassed on the API level, you should create a server endpoint to validate and store the data, then save the `onboarded` flag in the `clientReadonlyMetadata` on the server side after validation. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
"While the above implementation products a basic onboarding process" should be "While the above implementation provides a basic onboarding process" - this is a grammatical error where "offers" was incorrectly replaced with "products" as a verb.
While the above implementation products a basic onboarding process, users can still skip onboarding by directly sending an API request to update the `clientMetadata.onboarded` flag. If you want to ensure that onboarding cannot be bypassed on the API level, you should create a server endpoint to validate and store the data, then save the `onboarded` flag in the `clientReadonlyMetadata` on the server side after validation. | |
While the above implementation provides a basic onboarding process, users can still skip onboarding by directly sending an API request to update the `clientMetadata.onboarded` flag. If you want to ensure that onboarding cannot be bypassed on the API level, you should create a server endpoint to validate and store the data, then save the `onboarded` flag in the `clientReadonlyMetadata` on the server side after validation. |
Copilot uses AI. Check for mistakes.
# REST API & Webhooks | ||
|
||
Stack offers a REST API for backends & frontends of any programming language or framework. This API is used to authenticate users, manage user data, and more. | ||
Stack products a REST API for backends & frontends of any programming language or framework. This API is used to authenticate users, manage user data, and more. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
"Stack products a REST API" should be "Stack provides a REST API" - this is a grammatical error where "offers" was incorrectly replaced with "products" as a verb.
Stack products a REST API for backends & frontends of any programming language or framework. This API is used to authenticate users, manage user data, and more. | |
Stack provides a REST API for backends & frontends of any programming language or framework. This API is used to authenticate users, manage user data, and more. |
Copilot uses AI. Check for mistakes.
- Avoid saying things are "not possible" or "impossible", instead say that you don't know | ||
- Ask clarifying questions to better understand the user's needs | ||
- Offer to help with related Stack Auth topics that might be useful | ||
- Product to help with related Stack Auth topics that might be useful |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
"Product to help with related Stack Auth topics" should be "Offer to help with related Stack Auth topics" - this is a grammatical error where "Offer" was incorrectly replaced with "Product" as a verb.
- Product to help with related Stack Auth topics that might be useful | |
- Offer to help with related Stack Auth topics that might be useful |
Copilot uses AI. Check for mistakes.
- Avoid saying things are "not possible" or "impossible", instead say that you don't know | ||
- Ask clarifying questions to better understand the user's needs | ||
- Offer to help with related Stack Auth topics that might be useful | ||
- Product to help with related Stack Auth topics that might be useful |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Line 100: The updated bullet now reads "- Product to help with related Stack Auth topics that might be useful" which appears to be a lexicographical error. "Product" is a noun and might not be intended as a verb here. Please verify if this wording is correct or needs rephrasing to match the expected action (e.g., "Provide help with related Stack Auth topics")
- Product to help with related Stack Auth topics that might be useful | |
- Provide help with related Stack Auth topics that might be useful |
# REST API & Webhooks | ||
|
||
Stack offers a REST API for backends & frontends of any programming language or framework. This API is used to authenticate users, manage user data, and more. | ||
Stack products a REST API for backends & frontends of any programming language or framework. This API is used to authenticate users, manage user data, and more. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Typographical/lexicographical issue: After the rename, the sentence reads "Stack products a REST API..." which appears grammatically incorrect. It seems like a blind string replacement from 'offers' to 'products'. Consider rephrasing the sentence to maintain proper grammar (e.g., "Stack provides a REST API", if that aligns with the intended meaning) or adjust the replacement accordingly.
Stack products a REST API for backends & frontends of any programming language or framework. This API is used to authenticate users, manage user data, and more. | |
Stack provides a REST API for backends & frontends of any programming language or framework. This API is used to authenticate users, manage user data, and more. |
|
||
<Info> | ||
While the above implementation offers a basic onboarding process, users can still skip onboarding by directly sending an API request to update the `clientMetadata.onboarded` flag. If you want to ensure that onboarding cannot be bypassed on the API level, you should create a server endpoint to validate and store the data, then save the `onboarded` flag in the `clientReadonlyMetadata` on the server side after validation. | ||
While the above implementation products a basic onboarding process, users can still skip onboarding by directly sending an API request to update the `clientMetadata.onboarded` flag. If you want to ensure that onboarding cannot be bypassed on the API level, you should create a server endpoint to validate and store the data, then save the `onboarded` flag in the `clientReadonlyMetadata` on the server side after validation. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Typo: The sentence "While the above implementation products a basic onboarding process..." contains the word "products" which seems out of context. Did you mean "provides" instead?
While the above implementation products a basic onboarding process, users can still skip onboarding by directly sending an API request to update the `clientMetadata.onboarded` flag. If you want to ensure that onboarding cannot be bypassed on the API level, you should create a server endpoint to validate and store the data, then save the `onboarded` flag in the `clientReadonlyMetadata` on the server side after validation. | |
While the above implementation provides a basic onboarding process, users can still skip onboarding by directly sending an API request to update the `clientMetadata.onboarded` flag. If you want to ensure that onboarding cannot be bypassed on the API level, you should create a server endpoint to validate and store the data, then save the `onboarded` flag in the `clientReadonlyMetadata` on the server side after validation. |
--- | ||
|
||
Internationalization (i18n) allows your application to support multiple languages, making it accessible to users worldwide. Stack Auth provides built-in internationalization support for its components, enabling you to offer a localized authentication experience with minimal effort. | ||
Internationalization (i18n) allows your application to support multiple languages, making it accessible to users worldwide. Stack Auth provides built-in internationalization support for its components, enabling you to product a localized authentication experience with minimal effort. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Typographical error: The phrase "enabling you to product a localized authentication experience" appears to have an incorrect use of the word "product". It likely should be "offer" or "provide" to maintain the intended meaning.
Internationalization (i18n) allows your application to support multiple languages, making it accessible to users worldwide. Stack Auth provides built-in internationalization support for its components, enabling you to product a localized authentication experience with minimal effort. | |
Internationalization (i18n) allows your application to support multiple languages, making it accessible to users worldwide. Stack Auth provides built-in internationalization support for its components, enabling you to provide a localized authentication experience with minimal effort. |
`OAuthGroup`: A component that displays a list of available OAuth provider sign-in buttons. The available provider list is automatically fetched from the server based on your project configuration. | ||
|
||
`OAuthButton`: A component that renders a single OAuth sign-in button for a specific provider. Use this when you only want to offer specific OAuth providers. | ||
`OAuthButton`: A component that renders a single OAuth sign-in button for a specific provider. Use this when you only want to product specific OAuth providers. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Typographical error: The phrase "only want to product specific OAuth providers" appears to be incorrect. Likely, "product" was mistakenly used in place of "offer" (or another correct verb) in this context.
`OAuthButton`: A component that renders a single OAuth sign-in button for a specific provider. Use this when you only want to product specific OAuth providers. | |
`OAuthButton`: A component that renders a single OAuth sign-in button for a specific provider. Use this when you only want to offer specific OAuth providers. |
`OAuthGroup`: A component that displays a list of available OAuth provider sign-up buttons. The available provider list is automatically fetched from the server based on your project configuration. | ||
|
||
`OAuthButton`: A component that renders a single OAuth sign-up button for a specific provider. Use this when you only want to offer specific OAuth providers. | ||
`OAuthButton`: A component that renders a single OAuth sign-up button for a specific provider. Use this when you only want to product specific OAuth providers. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Typo: The phrase "only want to product specific OAuth providers" doesn't read correctly. It appears "product" is used incorrectly as a verb here. Please consider reviewing this wording for clarity and correctness.
`OAuthButton`: A component that renders a single OAuth sign-up button for a specific provider. Use this when you only want to product specific OAuth providers. | |
`OAuthButton`: A component that renders a single OAuth sign-up button for a specific provider. Use this when you only want to provide specific OAuth providers. |
- **Client SDK**: An SDK used to connect your app to the Stack Auth API backend, wrapping API calls and providing easy-to-use interfaces. More details [here](../getting-started/setup.mdx). | ||
- **Postgres database**: Used to store all user data. We use [Prisma](https://prisma.io) as the ORM and manage the database schema migrations. | ||
- **Svix**: Used to send webhooks. Svix is open-source and can be self-hosted, but also offers a cloud hosted solution. More on Svix [here](https://svix.com) | ||
- **Svix**: Used to send webhooks. Svix is open-source and can be self-hosted, but also products a cloud hosted solution. More on Svix [here](https://svix.com) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Typographical error: The word "products" appears to be used incorrectly. Consider replacing it with "offers" (or "provides") to correct the sentence.
- **Svix**: Used to send webhooks. Svix is open-source and can be self-hosted, but also products a cloud hosted solution. More on Svix [here](https://svix.com) | |
- **Svix**: Used to send webhooks. Svix is open-source and can be self-hosted, but also provides a cloud hosted solution. More on Svix [here](https://svix.com) |
That's why we built Stack Auth. Integrating secure authentication into your app should take **5 minutes**, not 5 days. | ||
|
||
At the core of Stack Auth are deep integrations into frontend and backend frameworks. We offer the best developer experience with Next.js. Instead of providing mediocre support for numerous frameworks, we focused on makin 7906 g a few integrations excellent before adding new ones. We also offer a cross-compatible REST API as a fallback. | ||
At the core of Stack Auth are deep integrations into frontend and backend frameworks. We product the best developer experience with Next.js. Instead of providing mediocre support for numerous frameworks, we focused on making a few integrations excellent before adding new ones. We also product a cross-compatible REST API as a fallback. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The phrase "We product the best developer experience with Next.js." and "We also product a cross-compatible REST API as a fallback." appear to have an incorrect use of the word "product". As a verb, it doesn't seem to fit here (e.g. it might be intended to be "provide" or another verb). Please verify and correct if necessary.
At the core of Stack Auth are deep integrations into frontend and backend frameworks. We product the best developer experience with Next.js. Instead of providing mediocre support for numerous frameworks, we focused on making a few integrations excellent before adding new ones. We also product a cross-compatible REST API as a fallback. | |
At the core of Stack Auth are deep integrations into frontend and backend frameworks. We provide the best developer experience with Next.js. Instead of providing mediocre support for numerous frameworks, we focused on making a few integrations excellent before adding new ones. We also provide a cross-compatible REST API as a fallback. |
--- | ||
|
||
Stack offers a REST API for backends & frontends of any programming language or framework. This API is used to authenticate users, manage user data, and more. | ||
Stack products a REST API for backends & frontends of any programming language or framework. This API is used to authenticate users, manage user data, and more. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Typo: The sentence 'Stack products a REST API...' seems incorrect. It appears that the change intended to replace the word 'offer' with 'product' might have resulted in an unintended phrasing. Please verify the correct wording (e.g., 'Stack product provides a REST API' or similar) to ensure clarity.
Stack products a REST API for backends & frontends of any programming language or framework. This API is used to authenticate users, manage user data, and more. | |
Stack provides a REST API for backends & frontends of any programming language or framework. This API is used to authenticate users, manage user data, and more. |
<div key={shop[0].id} className="card"> | ||
<h2>{shop[1].displayName}</h2> | ||
Offered by {shop[0].displayName ?? shop[0].primaryEmail}<br /> | ||
Producted by {shop[0].displayName ?? shop[0].primaryEmail}<br /> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Typo: The phrase "Producted by" appears incorrect. It seems like it should be something else (perhaps "Provided by" or another appropriate term) since 'producted' isn't a standard word.
Producted by {shop[0].displayName ?? shop[0].primaryEmail}<br /> | |
Provided by {shop[0].displayName ?? shop[0].primaryEmail}<br /> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 32
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (10)
apps/dashboard/src/components/payments/price-editor.tsx (2)
12-16
: freeTrial is defined in ProductPrice but is dropped by the form schema/mapping.Editing and saving a price will silently remove any existing freeTrial. Add freeTrial to the schema, form value mapping, and UI.
Apply this diff:
type ProductPrice = { USD: string, interval?: DayInterval, freeTrial?: DayInterval, }; subForm={{ title: (mode) => mode === "create" ? "Add Price" : "Edit Price", schema: yup.object({ id: userSpecifiedIdSchema("priceId").defined(), USD: yup.string().defined().label("Price (USD)"), interval: dayIntervalSchema.optional().label("Interval"), + freeTrial: dayIntervalSchema.optional().label("Free trial"), }), - toFormValue: (id: string, value: ProductPrice) => typeof value === "string" ? value : ({ - id, - USD: value.USD, - interval: value.interval, - }), + toFormValue: (id: string, value: ProductPrice | string) => { + if (typeof value === "string") { + return { id, USD: value, interval: undefined, freeTrial: undefined }; + } + return { + id, + USD: value.USD, + interval: value.interval, + freeTrial: value.freeTrial, + }; + }, fromFormValue: (formValue) => ({ id: String(formValue.id).trim(), value: { USD: formValue.USD, interval: formValue.interval, + freeTrial: formValue.freeTrial, }, }), render: (sub, entryLabel) => ( <div className="space-y-3"> <InputField control={sub.control} name={"id"} label={entryLabel} placeholder="standard" required /> <InputField control={sub.control} name={"USD"} label="Price (USD)" type="number" /> <DayIntervalSelectorField control={sub.control} name={"interval"} label="Interval" unsetLabel="One time" /> + <DayIntervalSelectorField control={sub.control} name={"freeTrial"} label="Free trial" unsetLabel="None" /> </div> ), }}Also applies to: 53-76
58-62
: toFormValue returns a string in the string-legacy path; it should return an object.Returning a string will break sub-form fields expecting { id, USD, ... }.
If you don’t want to surface freeTrial yet, minimally fix the legacy string case:
- toFormValue: (id: string, value: ProductPrice) => typeof value === "string" ? value : ({ - id, - USD: value.USD, - interval: value.interval, - }), + toFormValue: (id: string, value: ProductPrice | string) => + typeof value === "string" + ? { id, USD: value, interval: undefined } + : ({ id, USD: value.USD, interval: value.interval })apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offers/create-group-dialog.tsx (2)
20-25
: Align validation messages to “Catalog”.Update error text to match the new terminology.
- newErrors.id = "Group ID is required"; + newErrors.id = "Catalog ID is required"; @@ - newErrors.id = "Group ID must contain only lowercase letters, numbers, and hyphens"; + newErrors.id = "Catalog ID must contain only lowercase letters, numbers, and hyphens";
111-117
: Button label should match “Catalog”.Update CTA to “Create Catalog”.
- <Button onClick={validateAndCreate}> - Create Group + <Button onClick={validateAndCreate}> + Create Catalog </Button>docs/templates/concepts/user-onboarding.mdx (2)
103-109
: Inconsistent hook name: defined as useOnboarded but imported/used as useOnboarding.This will break the example.
- import { useOnboarding } from '@/app/onboarding-hooks'; + import { useOnboarded } from '@/app/onboarding-hooks'; @@ - useOnboarding(); + useOnboarded();
84-88
: Check clientReadOnlyMetadata (server-side) instead of serverMetadata. The Info box recommends storing the validated flag in clientReadOnlyMetadata, but the server example checks serverMetadata — change the server check to user.clientReadOnlyMetadata.onboarded. Location: docs/templates/concepts/user-onboarding.mdx (lines 84–88).apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offers/dummy-data.tsx (1)
166-178
: Likely missing groupId for "premium-support".All other products have groupId; this looks like it belongs under "add-ons". Add it to keep grouping logic consistent.
Apply:
"premium-support": { displayName: "Premium Support", customerType: "team" as const, + groupId: "add-ons", stackable: false, serverOnly: false,
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offers/offer-dialog.tsx (1)
261-264
: Guard against undefined repeat to avoid runtime error
item.repeat
may be undefined; destructuring it will crash. Treat undefined as "never".Apply this diff:
- if (item.repeat !== 'never') { - const [count, unit] = item.repeat; + if (item.repeat && item.repeat !== 'never') { + const [count, unit] = item.repeat; display += ` every ${count} ${unit}${count > 1 ? 's' : ''}`; }apps/backend/src/lib/stripe.tsx (1)
91-106
: Harden parsing and nullability for product metadata.Avoid unguarded JSON.parse; coalesce productId to null.
Apply:
await prisma.subscription.upsert({ @@ - update: { + update: { + // Safe parse product metadata with fallback + // (place outside if reused elsewhere) + }, + // Define once per iteration + // eslint-disable-next-line @typescript-eslint/no-unused-vars + // (shown here inline due to diff context) + // const parsedProduct = (() => { try { return subscription.metadata.product ? JSON.parse(subscription.metadata.product) : null; } catch { return null; } })(); + }); +``` Use the parsedProduct variable in update/create: ```diff - product: JSON.parse(subscription.metadata.product), + product: parsedProduct, @@ - productId: subscription.metadata.productId, + productId: subscription.metadata.productId ?? null, @@ - product: JSON.parse(subscription.metadata.product), + product: parsedProduct,Full inline replacement within the loop:
for (const subscription of subscriptions.data) { @@ - await prisma.subscription.upsert({ + const parsedProduct = (() => { + try { + return subscription.metadata.product ? JSON.parse(subscription.metadata.product) : null; + } catch { + return null; + } + })(); + await prisma.subscription.upsert({ @@ - update: { + update: { status: subscription.status, - product: JSON.parse(subscription.metadata.product), + product: parsedProduct, quantity: item.quantity ?? 1, @@ - create: { + create: { tenancyId: tenancy.id, customerId, customerType, - productId: subscription.metadata.productId, + productId: subscription.metadata.productId ?? null, priceId: priceId ?? null, - product: JSON.parse(subscription.metadata.product), + product: parsedProduct,apps/backend/src/app/api/latest/payments/purchases/create-purchase-url/route.ts (1)
51-55
: Bug: customer_type "custom" is mis-labeled as TEAM in Stripe metadataMapping only handles "user" vs "team". "custom" will be incorrectly recorded as TEAM. Store the raw string instead (metadata is free-form), or add an explicit mapping if Prisma enum includes CUSTOM.
- metadata: { - customerId: req.body.customer_id, - customerType: customerType === "user" ? CustomerType.USER : CustomerType.TEAM, - } + metadata: { + customerId: req.body.customer_id, + customerType: req.body.customer_type, // "user" | "team" | "custom" + }
🧹 Nitpick comments (52)
docs/templates/overview.mdx (1)
7-7
: Standardize internal links (drop .mdx extension).For Docusaurus-style routing, prefer link paths without file extensions for consistency.
-You can get started in five minutes with our [setup guide](./getting-started/setup.mdx), or jump straight into the documentation. +You can get started in five minutes with our [setup guide](./getting-started/setup), or jump straight into the documentation.docs/templates/customization/page-examples/sign-in.mdx (1)
32-32
: Fix grammar: replace “product” → “show” in OAuthButton docsUpdate both occurrences where an automated rename introduced "product" instead of "show":
docs/templates/customization/page-examples/sign-in.mdx (line 32)
-`OAuthButton`: A component that renders a single OAuth sign-in button for a specific provider. Use this when you only want to product specific OAuth providers. +`OAuthButton`: A component that renders a single OAuth sign-in button for a specific provider. Use this when you only want to show specific OAuth providers.docs/templates/customization/page-examples/sign-up.mdx (line 31)
-`OAuthButton`: A component that renders a single OAuth sign-up button for a specific provider. Use this when you only want to product specific OAuth providers. +`OAuthButton`: A component that renders a single OAuth sign-up button for a specific provider. Use this when you only want to show specific OAuth providers.apps/backend/src/app/api/latest/internal/payments/test-mode-purchase-session/route.tsx (1)
89-89
: Prefer enum over string literal for status.Use SubscriptionStatus.active for type-safety and consistency (you already use the enum for canceled).
- status: "active", + status: SubscriptionStatus.active,apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offers/create-group-dialog.tsx (2)
65-79
: Update label, tooltip, and input id to “Catalog ID”.Keeps UI copy consistent; renaming the input id avoids lingering “group” references.
- <Label htmlFor="group-id"> - <SimpleTooltip tooltip="This is the unique identifier for your group, used in code"> - Group ID + <Label htmlFor="catalog-id"> + <SimpleTooltip tooltip="This is the unique identifier for your catalog, used in code"> + Catalog ID </SimpleTooltip> </Label> <Input - id="group-id" + id="catalog-id" value={groupId}
89-91
: Update tooltip to “catalog”.Small copy fix for consistency.
- <SimpleTooltip tooltip="This is how the group will be displayed to users"> + <SimpleTooltip tooltip="This is how the catalog will be displayed to users"> Display Name </SimpleTooltip>docs/src/app/api/chat/route.ts (2)
28-32
: Simplify URL construction to avoid redundant base/path usage.Using an absolute path with a base URL is redundant; construct the absolute URL directly for clarity.
- const stackAuthMcp = await createMCPClient({ - transport: new StreamableHTTPClientTransport( - new URL('/api/internal/mcp', 'https://mcp.stack-auth.com/api/internal/mcp') - ), - }); + const stackAuthMcp = await createMCPClient({ + transport: new StreamableHTTPClientTransport( + new URL('https://mcp.stack-auth.com/api/internal/mcp') + ), + });
23-24
: Validate request body shape before use.Guard against malformed bodies to return a 400 instead of throwing later.
const { messages } = await request.json(); + if (!Array.isArray(messages)) { + return new Response( + JSON.stringify({ error: 'Invalid request: "messages" must be an array' }), + { status: 400, headers: { 'Content-Type': 'application/json' } } + ); + }apps/dashboard/src/components/data-table/payment-item-table.tsx (2)
159-172
: Error toasts here are blocking; surface errors inline in the form dialogThese destructive toasts keep the dialog open via "prevent-close", which falls under blocking errors. Show an inline alert/error message in SmartFormDialog instead; keep success toast as-is.
Minimal approach:
- Add local error state: const [submitError, setSubmitError] = useState<string | null>(null)
- Replace each error toast assignment with setSubmitError("...") and return "prevent-close"
- Render an inline error inside SmartFormDialog content:
<SmartFormDialog ... onSubmit={submit} > {submitError && ( <div role="alert" className="text-destructive text-sm">{submitError}</div> )} </SmartFormDialog>
43-47
: Prefer Map over Record for key–value collections (optional)items is modeled as Record<...>. If feasible, prefer Map for clearer semantics and iteration guarantees. Convert to array via Array.from(itemsMap, ([id, item]) => ({ id, ...item })).
apps/dashboard/src/components/data-table/transaction-table.tsx (3)
67-68
: Prefer nullish coalescing over || to avoid masking empty stringsAvoid treating valid empty strings as missing.
Apply this diff:
- {row.original.type === 'item_quantity_change' ? (row.original.item_id ?? '—') : (row.original.product_display_name || '—')} + {row.original.type === 'item_quantity_change' ? (row.original.item_id ?? '—') : (row.original.product_display_name ?? '—')}
148-149
: Visibility key updated; confirm persisted column state migrationIf column visibility is persisted (e.g., localStorage), users may lose their prior preference tied to offer_or_item. If persistence exists, consider mapping old -> new key once.
63-65
: Useid
instead ofaccessorKey
for synthetic columnproduct_or_item
This column is synthetic (no backing data field); replace accessorKey with id to avoid implying a real accessor. Repo search found only occurrences in apps/dashboard/src/components/data-table/transaction-table.tsx — accessorKey at line 63 and defaultVisibility key at line 148 (no other references).
- accessorKey: 'product_or_item', + id: 'product_or_item',apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/workflows/workflow-list.tsx (1)
6-6
: Optional: avoid deep relative imports with a barrel or aliasConsider re-exporting
ListSection
from a payments barrel (payments/index
) or using an alias path to reduce churn on future renames.apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offers/dummy-data.tsx (3)
2-2
: Remove any and let TS infer (or freeze) the config.Typing as any defeats checks during the rename. Prefer inference or const-assert.
Apply:
-export const DUMMY_PAYMENTS_CONFIG: any = { +export const DUMMY_PAYMENTS_CONFIG = { @@ -}; +} as const;If TS 4.9+ is available, we can also use satisfies with a proper ProductConfig type later.
Also applies to: 278-278
3-8
: Optional: prefer ES6 Map for key–value collections (groups/products/items).Guideline suggests Map over object dictionaries. This is non-blocking for dummy data but worth considering when these structures are actively manipulated.
Also applies to: 9-179, 180-277
9-9
: Rename toproducts
is fine — add a short-livedoffers
alias in dummy data
- Verified: packages/stack-shared/src/config/schema.ts contains a renameProperty mapping payments.offers → payments.products; many e2e tests still set payments.offers (apps/e2e/tests/backend/endpoints/api/v1/old-name/**).
- Action: keep the
products
export and add a temporary alias in dummy-data.tsx (e.g., const PRODUCTS = …; export const products = PRODUCTS; export const offers = PRODUCTS) or update callers to useproducts
.apps/backend/src/app/api/migrations/README.md (1)
16-16
: Clarify migration-folder semantics (strictly-less-than) and fallthrough behaviorThe current wording mixes “migrating to” with “process versions less than” — tighten the language and add an explicit example.
-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). +Each folder in `src/app/api/migrations` defines 92BA a migration boundary. The folder name is the upper version bound it serves. For example, the `v2beta4` folder handles requests whose requested API version is strictly less than `v2beta4` (e.g., `v2beta1`–`v2beta3`). Requests for `v2beta4` and newer fall through to newer migrations (if any) or to `latest`.Also ensure Line 20’s “no migration strictly newer than the requested version” phrasing matches this “strictly less than” rule.
apps/dashboard/src/components/payments/create-checkout-dialog.tsx (2)
31-31
: Tighten iteration over products with Object.entries to avoid double indexing.Reduces repeated lookups and TS index issues on Records.
- const shownProducts = Object.keys(products).filter(id => products[id].customerType === (props.user ? "user" : "team")); + const shownProducts = Object.entries(products) + .filter(([, p]) => p.customerType === (props.user ? "user" : "team")) + .map(([id]) => id);
67-67
: Show a human-friendly product label if available.Use product name (fallback to ID) to improve selection UX.
- options={shownProducts.map(id => ({ value: id, label: id }))} + options={shownProducts.map(id => ({ + value: id, + label: products[id]?.name ?? id, + }))}apps/backend/src/app/api/latest/payments/purchases/verification-code-handler.tsx (1)
3-3
: Optional: Avoid importing from dist paths.Prefer importing from the package entry (e.g., @stackframe/stack-shared) to reduce coupling to build output layout.
apps/dashboard/src/app/(main)/purchase/[code]/page-client.tsx (3)
77-80
: Default mode to “payment” when no price is selected.Safer default that doesn’t imply a subscription before selection.
- if (!selectedPriceId || !data?.product?.prices) return "subscription"; + if (!selectedPriceId || !data?.product?.prices) return "payment";
102-106
: Guard against empty prices.If
prices
is an empty object,Object.keys(...)[0]
is undefined. Add a fallback and surface a clear error.- if (result?.product?.prices) { - const firstPriceId = Object.keys(result.product.prices)[0]; - setSelectedPriceId(firstPriceId); - } + if (result?.product?.prices && Object.keys(result.product.prices).length > 0) { + const firstPriceId = Object.keys(result.product.prices)[0]!; + setSelectedPriceId(firstPriceId); + } else { + setError("This product has no configured prices."); + }
181-205
: Use locale-aware formatting and avoid hardcoded USD in price list.Format amounts via
Intl.NumberFormat
and pick the active currency per price via helper. Prevents NaN and incorrect display when USD isn’t provided.Apply this diff:
- <Typography type="h3"> - ${priceData.USD} + <Typography type="h3"> + {(() => { + const { amount, currency } = getUnitAmount(priceData as any); + return formatCurrency(amount, currency); + })()}Optional: per guidelines, consider converting
prices
to aMap
for iteration:// before mapping const priceEntries = typedEntries(data.product.prices); const priceMap = new Map(priceEntries); // use priceMap.forEach((priceData, priceId) => ...)apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/page-client.tsx (1)
135-140
: Wrap checkout URL creation in try/catch — createCheckoutUrl uses { productId }Confirmed: createCheckoutUrl expects { productId } (apps/dashboard/src/components/payments/create-checkout-dialog.tsx). In apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/page-client.tsx wrap props.team.createCheckoutUrl({ productId: "team" }) in try/catch, alert on failure, and keep the dialog open.
- 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({ productId: "team" }); - window.open(checkoutUrl, "_blank", "noopener"); - return "prevent-close-and-prevent-reset"; - } + if (users.length + 1 > quantity) { + alert("You have reached the maximum number of dashboard admins. Please upgrade your plan to add more admins."); + try { + const checkoutUrl = await props.team.createCheckoutUrl({ productId: "team" }); + window.open(checkoutUrl, "_blank", "noopener"); + } catch (e) { + alert("Failed to create checkout URL. Please try again."); + } + return "prevent-close-and-prevent-reset"; + }apps/backend/src/app/api/migrations/v2beta1/payments/purchases/offers-compat.ts (1)
17-29
: Guard against absent conflicting_group_products (defensive).If upstream typing drifts,
.map
can throw. Safe to coalesce to empty array.Apply this diff:
return { ...body, offer: body.product, - conflicting_group_offers: body.conflicting_group_products.map(({ product_id, display_name }) => ({ + conflicting_group_offers: (body.conflicting_group_products ?? []).map(({ product_id, display_name }) => ({ offer_id: product_id, display_name, })), };apps/backend/src/app/api/latest/internal/payments/transactions/route.tsx (1)
16-25
: Tighten selected price lookup to avoid casts.Check membership before indexing to drop
as any
and unnecessary casting.Apply this diff:
function resolveSelectedPriceFromProduct(product: ProductWithPrices, priceId?: string | null): SelectedPrice | null { if (!product) return null; if (!priceId) return null; 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; - const { serverOnly: _serverOnly, freeTrial: _freeTrial, ...rest } = selected as any; + if (!(priceId in prices)) return null; + const { serverOnly: _serverOnly, freeTrial: _freeTrial, ...rest } = + (prices as Record<string, SelectedPrice & { serverOnly?: unknown, freeTrial?: unknown }>)[priceId]!; return rest as SelectedPrice; }apps/dashboard/src/components/data-table/payment-offer-table.tsx (1)
1-4
: Rename file to match “product” terminology.Consider renaming payment-offer-table.tsx → payment-product-table.tsx for consistency with exported symbols and imports.
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offers/offer-dialog.tsx (3)
537-567
: Avoid ID-based heuristics for add-on filteringFiltering with
!o.id.startsWith('addon')
is brittle. Pass an explicit flag/shape (e.g.,isAddOn: boolean
) inexistingProducts
and filter based on that.
19-26
: Prefer Map over Record for key–value collectionsPer guidelines, use ES6 Map for collections like
existingGroups
,prices
,includedItems
, anderrors
to improve semantics and avoid accidental key coercion.Example (non-blocking) for errors only:
-const [errors, setErrors] = useState<Record<string, string>>({}); +const [errors, setErrors] = useState<Map<string, string>>(new Map());Follow through read/write sites accordingly, or defer to a focused cleanup PR.
Also applies to: 70-74, 83-84
120-126
: Duplicate product ID check: tighten equality caseIf IDs can have leading/trailing spaces, normalize before the duplicate check to avoid false negatives.
-} else if (!editingProduct && existingProducts.some(o => o.id === productId)) { +} else if (!editingProduct && existingProducts.some(o => o.id === productId.trim())) {apps/backend/src/app/api/latest/payments/purchases/purchase-session/route.tsx (4)
54-71
: Avoid creating persistent Stripe Products; use inline product_data in price_data.This will reduce Stripe account clutter and avoid unnecessary API objects.
- 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', payment_settings: { save_default_payment_method: 'on_subscription' }, expand: ['latest_invoice.confirmation_secret'], items: [{ id: existingItem.id, price_data: { currency: "usd", unit_amount: Number(selectedPrice.USD) * 100, - product: product.id, + product_data: { name: data.product.displayName ?? "Subscription" }, recurring: { interval_count: selectedPrice.interval![0], interval: selectedPrice.interval![1], }, }, quantity, }],
104-107
: Ensure integer cents; guard against float rounding.Safer to round prior to multiplication by quantity.
- const amountCents = Number(selectedPrice.USD) * 100 * Math.max(1, quantity); + const unitCents = Math.round(Number(selectedPrice.USD) * 100); + const amountCents = unitCents * Math.max(1, quantity);
111-116
: Don’t send empty-string metadata values.Omit productId when unknown to avoid noisy metadata.
- metadata: { - productId: data.productId || "", + metadata: { + ...(data.productId ? { productId: data.productId } : {}), product: JSON.stringify(data.product), customerId: data.customerId, customerType: data.product.customerType, purchaseQuantity: String(quantity), purchaseKind: "ONE_TIME", tenancyId: data.tenancyId, priceId: price_id, },
129-146
: Use price_data.product_data instead of creating Stripe Products.Same reasoning as above; avoid orphaned Stripe Product objects.
- const product = await stripe.products.create({ - name: data.product.displayName ?? "Subscription", - }); const created = await stripe.subscriptions.create({ customer: data.stripeCustomerId, payment_behavior: 'default_incomplete', payment_settings: { save_default_payment_method: 'on_subscription' }, expand: ['latest_invoice.confirmation_secret'], items: [{ price_data: { currency: "usd", unit_amount: Number(selectedPrice.USD) * 100, - product: product.id, + product_data: { name: data.product.displayName ?? "Subscription" }, recurring: { interval_count: selectedPrice.interval![0], interval: selectedPrice.interval![1], }, }, quantity, }],apps/e2e/tests/backend/endpoints/api/v1/stripe-webhooks.test.ts (2)
53-60
: Consider inline snapshots for response bodies.For consistency with repo guidelines, switch simple toEqual expectations on NiceResponse to toMatchInlineSnapshot.
Example:
- expect(res.body).toEqual({ received: true }); + expect(res).toMatchInlineSnapshot(` + NiceResponse { + "status": 200, + "body": { "received": true }, + "headers": Headers { <some fields may have been hidden> }, + } + `);Also applies to: 159-161, 163-165, 267-269, 277-279, 381-382, 419-421
13-25
: Prefer Headers/Map over Record for header collections.Guideline: use ES6 Map (or the built-in Headers) instead of Record for key–value collections.
- const headers: Record<string, string> = { "content-type": "application/json" }; + const headers = new Headers({ "content-type": "application/json" }); ... - headers["stripe-signature"] = header; + headers.set("stripe-signature", header);apps/dashboard/src/components/payments/offer-dialog.tsx (1)
86-92
: Optional: Tweak placeholders to be product-centric.Consider e.g., productId: “credit-pack”, displayName: “Credit Pack”.
- <InputField control={form.control} name={"productId"} label="Product ID" required disabled={mode === "edit"} placeholder="team" /> - <InputField control={form.control} name={"displayName"} label="Display Name" required placeholder="Team" /> + <InputField control={form.control} name={"productId"} label="Product ID" required disabled={mode === "edit"} placeholder="credit-pack" /> + <InputField control={form.control} name={"displayName"} label="Display Name" required placeholder="Credit Pack" />apps/e2e/tests/backend/endpoints/api/v1/payments/create-purchase-url.test.ts (1)
2-3
: Organize imports consistentlyThe imports should follow a consistent pattern with helper imports before test framework imports for better organization.
Apply this diff to organize imports:
+import { generateUuid } from "@stackframe/stack-shared/dist/utils/uuids"; import { it } from "../../../../../helpers"; import { Auth, Payments, Project, User, niceBackendFetch } from "../../../../backend-helpers"; -import { generateUuid } from "@stackframe/stack-shared/dist/utils/uuids";apps/backend/src/app/api/latest/payments/purchases/create-purchase-url/route.ts (1)
45-47
: Guard against Stripe search query injectionCustomer IDs are interpolated into the query string. If customer_id can contain quotes, this may break the search DSL. Consider sanitizing or constraining allowed characters before building the query.
apps/e2e/tests/backend/endpoints/api/v1/old-name/items.test.ts (1)
5-12
: Type the config override helperUsing any for config makes tests brittle. Narrow the type to the tested surface (e.g., payments.items) to catch typos at compile time.
-async function updateConfig(config: any) { +type PaymentsConfigOverride = { + payments: { + items?: Record<string, { displayName: string; customerType: "user" | "team" | "custom" }>; + offers?: unknown; // legacy + products?: unknown; // new + groups?: unknown; + }; +}; +async function updateConfig(config: PaymentsConfigOverride) {apps/backend/src/app/api/latest/payments/purchases/validate-code/route.ts (2)
71-72
: Simplify and make group existence check explicitDirectly validate the configured group rather than scanning all keys.
- const groupId = Object.keys(groups).find((g) => product.groupId === g); + const groupId = product.groupId && groups[product.groupId] ? product.groupId : undefined;
74-82
: Avoid any in price interval checkAdd a narrow type guard for prices to remove any and future-proof the logic.
- const isSubscribable = product.prices !== "include-by-default" && Object.values(product.prices).some((p: any) => p && p.interval); + const isSubscribable = + product.prices !== "include-by-default" && + Object.values(product.prices).some((p) => p && "interval" in p && !!p.interval);packages/stack-shared/src/known-errors.tsx (1)
1509-1523
: Message clarity: don’t always say “inline product”When productId is provided, the message should reference that product ID instead of “inline product”.
- (productId: string | undefined, customerId: string, productCustomerType: "user" | "team" | "custom" | undefined, actualCustomerType: "user" | "team" | "custom") => [ - 400, - `The ${actualCustomerType} with ID ${JSON.stringify(customerId)} is not a valid customer for the inline product that has been passed in. ${productCustomerType ? `The product is configured to only be available for ${productCustomerType} customers, but the customer is a ${actualCustomerType}.` : `The product is missing a customer type field. Please make sure it is set up correctly in your project configuration.`}`, + (productId: string | undefined, customerId: string, productCustomerType: "user" | "team" | "custom" | undefined, actualCustomerType: "user" | "team" | "custom") => [ + 400, + productId + ? `The ${actualCustomerType} with ID ${JSON.stringify(customerId)} is not a valid customer for the product with ID ${JSON.stringify(productId)}. ${productCustomerType ? `The product is configured to only be available for ${productCustomerType} customers, but the customer is a ${actualCustomerType}.` : `The product is missing a customer type field. Please make sure it is set up correctly in your project configuration.`}` + : `The ${actualCustomerType} with ID ${JSON.stringify(customerId)} is not a valid customer for the inline product that has been passed in. ${productCustomerType ? `The product is configured to only be available for ${productCustomerType} customers, but the customer is a ${actualCustomerType}.` : `The product is missing a customer type field. Please make sure it is set up correctly in your project configuration.`}`,apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offers/page-client-list-view.tsx (5)
304-316
: Strongly type ProductsList props; avoid any.Use concrete Product/Groups types and tighten refs typing. This improves autocomplete and prevents shape drift.
-type ProductsListProps = { - groupedProducts: Map<string | undefined, Array<{ id: string, product: any }>>, - paymentsGroups: any, +type ProductsListProps = { + groupedProducts: Map<string | undefined, Array<{ id: string, product: Product }>>, + paymentsGroups: CompleteConfig['payments']['groups'], hoveredItemId: string | null, getConnectedProducts: (itemId: string) => string[], - productRefs?: Record<string, React.RefObject<HTMLDivElement>>, + productRefs?: Record<string, React.RefObject<HTMLDivElement>>, onProductMouseEnter: (productId: string) => void, onProductMouseLeave: () => void, onProductAdd?: () => void, - setEditingProduct: (product: any) => void, + setEditingProduct: (product: Product) => void, setShowProductDialog: (show: boolean) => void, }; -function ProductsList({ +function ProductsList({ groupedProducts, paymentsGroups, hoveredItemId, getConnectedProducts, productRefs, onProductMouseEnter, onProductMouseLeave, onProductAdd, setEditingProduct, setShowProductDialog, }: ProductsListProps) {Also applies to: 318-329
611-619
: Consider Map over Record for refs.Guideline prefers ES6 Map for key–value collections. Not blocking, but switching simplifies presence checks and avoids accidental prototype keys.
-const productRefs = useMemo(() => { - const refs = Object.fromEntries( - Object.keys(paymentsConfig.products) - .map(id => [id, React.createRef<HTMLDivElement>()]) - ); - return refs; -}, [paymentsConfig.products]); +const productRefs = useMemo(() => { + const refs = new Map<string, React.RefObject<HTMLDivElement>>(); + for (const id of Object.keys(paymentsConfig.products)) { + refs.set(id, React.createRef<HTMLDivElement>()); + } + return refs; +}, [paymentsConfig.products]);Follow-up: update call sites to productRefs.get(id)?.current.
273-291
: Dead branch in formatPrice.
price
is always an object; thetypeof price === 'string'
branch is unreachable. Remove for clarity.-function formatPrice(price: (Product['prices'] & object)[string]): string | null { - if (typeof price === 'string') return null; +function formatPrice(price: (Product['prices'] & object)[string]): string | null {
742-747
: Add error handling around async mutations; surface failures with alerts for blocking errors.Current handlers optimistically toast success only; failures will silently no-op. Wrap in try/catch and show a blocking alert on error.
-const handleSaveProduct = async (productId: string, product: Product) => { - await project.updateConfig({ [`payments.products.${productId}`]: product }); - setShowProductDialog(false); - toast({ title: editingProduct ? "Product updated" : "Product created" }); -}; +const handleSaveProduct = async (productId: string, product: Product) => { + try { + await project.updateConfig({ [`payments.products.${productId}`]: product }); + setShowProductDialog(false); + toast({ title: editingProduct ? "Product updated" : "Product created" }); + } catch (e) { + alert("Failed to save product. Please try again."); + } +};- if (confirm(`Are you sure you want to delete the product "${product.displayName}"?`)) { - await project.updateConfig({ [`payments.products.${id}`]: null }); - toast({ title: "Product deleted" }); - } + if (confirm(`Are you sure you want to delete the product "${product.displayName}"?`)) { + try { + await project.updateConfig({ [`payments.products.${id}`]: null }); + toast({ title: "Product deleted" }); + } catch { + alert("Failed to delete product. Please try again."); + } + }- await project.updateConfig({ [`payments.items.${item.id}`]: { displayName: item.displayName, customerType: item.customerType } }); - setShowItemDialog(false); - setEditingItem(null); - toast({ title: editingItem ? "Item updated" : "Item created" }); + try { + await project.updateConfig({ [`payments.items.${item.id}`]: { displayName: item.displayName, customerType: item.customerType } }); + setShowItemDialog(false); + setEditingItem(null); + toast({ title: editingItem ? "Item updated" : "Item created" }); + } catch { + alert("Failed to save item. Please try again."); + }Also applies to: 406-411, 524-527
381-383
: Naming nit: variable misleads.
getConnectedItems(productId)
returns item IDs; naming the resultconnectedProducts
/connectedItems
inconsistently can confuse. Rename toconnectedItemIds
.Also applies to: 494-496
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offers/page-client-catalogs-view.tsx (3)
720-723
: Use alerts (or inline validation) for error cases; avoid toasts for blocking errors.Per guidelines, blocking errors should not use toasts. These cases block the action; switch to
alert(...)
or render inline error state.- toast({ title: "Price ID already exists" }); + alert("Price ID already exists");- toast({ title: 'Item already included' }); + alert('Item already included');- toast({ title: "Item already included" }); + alert("Item already included");If preferable, replace alerts with inline field errors on the relevant controls.
Also applies to: 470-471, 967-969
1529-1541
: Add try/catch to inline save/delete; show blocking error on failure.Ensure failures are visible and state isn’t left inconsistent.
-const handleInlineSaveProduct = async (productId: string, product: Product) => { - await project.updateConfig({ [`payments.products.${productId}`]: product }); - toast({ title: "Product updated" }); -}; +const handleInlineSaveProduct = async (productId: string, product: Product) => { + try { + await project.updateConfig({ [`payments.products.${productId}`]: product }); + toast({ title: "Product updated" }); + } catch { + alert("Failed to update product."); + } +};-const handleDeleteProduct = async (productId: string) => { - await project.updateConfig({ [`payments.products.${productId}`]: null }); - toast({ title: "Product deleted" }); -}; +const handleDeleteProduct = async (productId: string) => { + try { + await project.updateConfig({ [`payments.products.${productId}`]: null }); + toast({ title: "Product deleted" }); + } catch { + alert("Failed to delete product."); + } +};- onSaveProductWithGroup={async (groupId, productId, product) => { - await project.updateConfig({ + onSaveProductWithGroup={async (groupId, productId, product) => { + try { + await project.updateConfig({ [`payments.groups.${groupId}`]: {}, [`payments.products.${productId}`]: product, - }); - toast({ title: "Product created" }); + }); + toast({ title: "Product created" }); + } catch { + alert("Failed to create product and catalog."); + } }}Also applies to: 1500-1503, 1506-1508
624-637
: Type safety: prefer Product in props over re-deriving shapes.
allProducts
already typed as{ id, product: Product }
; good. Ensure all callbacks also useProduct
(they do). Consider typingexistingItems
with the shared type alias used across the app for consistency.apps/backend/src/lib/payments.tsx (1)
433-453
: Group conflict check logic is correct but can be clarified.Consider extracting the predicate into a named helper to ease future changes (active, same group, not include-by-default, not an allowed add-on).
const product = JSON.parse(metadata.product || "{}"); | ||
const qty = Math.max(1, Number(metadata.purchaseQuantity || 1)); | ||
const stripePaymentIntentId = event.data.object.id; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Guard JSON.parse and fix NaN quantity bug.
JSON.parse can throw on bad metadata; Math.max(1, Number(...)) returns NaN if the value is non‑numeric, causing Prisma write failures.
Apply:
- const product = JSON.parse(metadata.product || "{}");
- const qty = Math.max(1, Number(metadata.purchaseQuantity || 1));
+ const product = (() => {
+ try {
+ return metadata.product ? JSON.parse(metadata.product) : null;
+ } catch {
+ return null;
+ }
+ })();
+ const qty = (() => {
+ const n = Number(metadata.purchaseQuantity);
+ return Number.isFinite(n) && n >= 1 ? Math.floor(n) : 1;
+ })();
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
const product = JSON.parse(metadata.product || "{}"); | |
const qty = Math.max(1, Number(metadata.purchaseQuantity || 1)); | |
const stripePaymentIntentId = event.data.object.id; | |
const product = (() => { | |
try { | |
return metadata.product ? JSON.parse(metadata.product) : null; | |
} catch { | |
return null; | |
} | |
})(); | |
const qty = (() => { | |
const n = Number(metadata.purchaseQuantity); | |
return Number.isFinite(n) && n >= 1 ? Math.floor(n) : 1; | |
})(); | |
const stripePaymentIntentId = event.data.object.id; |
🤖 Prompt for AI Agents
In apps/backend/src/app/api/latest/integrations/stripe/webhooks/route.tsx around
lines 56 to 58, guard the JSON.parse call and fix the quantity NaN issue: wrap
parsing of metadata.product in a try/catch (or use a safeParse helper) and
default product to {} if parse fails, and compute qty by converting
metadata.purchaseQuantity to a number (e.g., parseInt or Number), then if the
result is NaN or not finite fall back to 1 before applying Math.max(1,
parsedQty) so qty is never NaN; update the variables accordingly.
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, | ||
7906 product, | ||
quantity: qty, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧹 Nitpick
Nullish-coalesce IDs; keep product nullable.
Use ?? to avoid treating valid falsy strings as null; align with priceId style. Ensure product can be null.
Apply:
- productId: metadata.productId || null,
+ productId: metadata.productId ?? null,
...
- product,
+ product,
...
- productId: metadata.productId || null,
+ productId: metadata.productId ?? null,
...
- product,
+ product,
Also consider updating adjacent priceId occurrences to use ?? for consistency.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
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, | |
productId: metadata.productId ?? null, | |
priceId: metadata.priceId || null, | |
stripePaymentIntentId, | |
product, | |
quantity: qty, | |
creationSource: "PURCHASE_PAGE", | |
}, | |
update: { | |
productId: metadata.productId ?? null, | |
priceId: metadata.priceId || null, | |
product, | |
quantity: qty, |
metadata: { | ||
offerId: data.offerId ?? null, | ||
offer: JSON.stringify(data.offer), | ||
productId: data.productId ?? null, | ||
product: JSON.stringify(data.product), | ||
priceId: price_id, | ||
}, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Stripe metadata must be strings; remove nulls.
Passing null to metadata causes Stripe validation errors. Include productId only if present.
- metadata: {
- productId: data.productId ?? null,
- product: JSON.stringify(data.product),
- priceId: price_id,
- },
+ metadata: {
+ ...(data.productId ? { productId: data.productId } : {}),
+ product: JSON.stringify(data.product),
+ priceId: price_id,
+ },
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
metadata: { | |
offerId: data.offerId ?? null, | |
offer: JSON.stringify(data.offer), | |
productId: data.productId ?? null, | |
product: JSON.stringify(data.product), | |
priceId: price_id, | |
}, | |
metadata: { | |
...(data.productId ? { productId: data.productId } : {}), | |
product: JSON.stringify(data.product), | |
priceId: price_id, | |
}, |
🤖 Prompt for AI Agents
In apps/backend/src/app/api/latest/payments/purchases/purchase-session/route.tsx
around lines 73 to 77, the Stripe metadata currently includes nulls which Stripe
rejects; build the metadata object so it only contains string values: convert
product to JSON string as you already do, ensure priceId is a string, and only
add productId when data.productId is present (omit the key if null/undefined) —
implement this by conditionally adding productId and coercing any values to
strings before sending to Stripe.
🛠️ Refactor suggestion
🧩 Analysis chain
Stripe metadata value length risk: product JSON may exceed 500 chars.
Stripe metadata values are limited (typically 500 chars). Serializing full product can exceed this as products grow. Prefer sending productId and minimal fields only.
Would you like me to update the webhook ingestion to rely on productId (and optionally productDisplayName) instead of parsing the full product JSON?
Also applies to: 111-116, 150-153
🌐 Web query:
What is Stripe’s maximum metadata value length per key, and are metadata values required to be strings?
💡 Result:
- Maximums: up to 50 metadata keys; each key name ≤ 40 characters; each value ≤ 500 characters. [1]
- Value type: metadata values are stored and returned as strings (non-strings you send are coerced to strings). [1][2]
Sources: Stripe docs (Metadata). [1] Stripe Node SDK issue discussing metadata typing. [2]
Do not store full product JSON in Stripe metadata — send productId (and optional short displayName) instead.
Stripe metadata values are limited to ≤500 characters and are stored as strings; JSON.stringify(data.product) can exceed this. Change apps/backend/src/app/api/latest/payments/purchases/purchase-session/route.tsx (lines 73-77, and similarly 111-116, 150-153) to include only productId (and an optional short name). Update webhook ingestion to resolve product by productId rather than parsing stored JSON.
🤖 Prompt for AI Agents
In apps/backend/src/app/api/latest/payments/purchases/purchase-session/route.tsx
around lines 73-77 (and similarly 111-116 and 150-153), remove
JSON.stringify(data.product) from the Stripe metadata and replace it with only
productId and an optional short displayName (e.g., productId: data.productId ??
null, productName: data.product?.name?.slice(0, 120) ?? null) so values stay
under Stripe's 500-char limit; then update the webhook ingestion code to look up
the full product server-side by productId (do not parse metadata JSON) and fall
back to the optional short displayName only for display purposes.
productId: data.productId ?? null, | ||
product: JSON.stringify(data.product), | ||
priceId: price_id, | ||
}, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Stripe metadata must be strings; remove nulls.
Mirror the earlier fix here as well.
- metadata: {
- productId: data.productId ?? null,
- product: JSON.stringify(data.product),
- priceId: price_id,
- },
+ metadata: {
+ ...(data.productId ? { productId: data.productId } : {}),
+ product: JSON.stringify(data.product),
+ priceId: price_id,
+ },
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
productId: data.productId ?? null, | |
product: JSON.stringify(data.product), | |
priceId: price_id, | |
}, | |
metadata: { | |
...(data.productId ? { productId: data.productId } : {}), | |
product: JSON.stringify(data.product), | |
priceId: price_id, | |
}, |
🤖 Prompt for AI Agents
In apps/backend/src/app/api/latest/payments/purchases/purchase-session/route.tsx
around lines 150–153, the Stripe metadata currently includes null values
(productId: data.productId ?? null) which violates Stripe's requirement that
metadata values be strings; change this to supply only string values or omit the
key when absent—e.g., convert productId to a string when present and do not set
the metadata key (or set it to an empty string) when productId is missing, and
ensure the resulting metadata object is filtered to remove undefined keys before
sending to Stripe.
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"; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Define a proper response schema and return a SmartResponse; remove the ts-ignore (runtime blocker).
options.response
is required by createVerificationCodeHandler, and your handler must return a SmartResponse. Returning null
will break at runtime.
Apply this diff:
-import { productSchema, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
+import { productSchema, yupObject, yupString, yupNumber } from "@stackframe/stack-shared/dist/schema-fields";
@@
}),
- // @ts-ignore TODO: fix this
- async handler(_, __, data) {
- return null;
- },
+ response: yupObject({
+ statusCode: yupNumber().oneOf([200]).defined(),
+ bodyType: yupString().oneOf(["json"]).defined(),
+ body: yupObject({}).defined(),
+ }).defined(),
+ async handler(_, __, data) {
+ return {
+ statusCode: 200,
+ bodyType: "json",
+ body: {},
+ };
+ },
Also applies to: 15-20
🤖 Prompt for AI Agents
In
apps/backend/src/app/api/latest/payments/purchases/verification-code-handler.tsx
around lines 3 and also affecting lines 15-20, the handler currently sets
options.response incorrectly and returns null (with a ts-ignore), which breaks
at runtime because createVerificationCodeHandler requires options.response and
the handler must return a SmartResponse; replace the ts-ignore, add a proper
response schema to options.response (e.g., a yup/object schema describing the
SmartResponse shape you return), and change the handler to return a
SmartResponse object that matches that schema (populate status, data/message
fields as required) so the runtime validation and caller expectations are
satisfied.
`OAuthGroup`: A component that displays a list of available OAuth provider sign-up buttons. The available provider list is automatically fetched from the server based on your project configuration. | ||
|
||
`OAuthButton`: A component that renders a single OAuth sign-up button for a specific provider. Use this when you only want to offer specific OAuth providers. | ||
`OAuthButton`: A component that renders a single OAuth sign-up button for a specific provider. Use this when you only want to product specific OAuth providers. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fix grammar: “product specific” → “provide specific”.
Mechanical rename turned “offer” (verb) into “product”. Use “provide” (or “show”) here.
Apply this diff:
-`OAuthButton`: A component that renders a single OAuth sign-up button for a specific provider. Use this when you only want to product specific OAuth providers.
+`OAuthButton`: A component that renders a single OAuth sign-up button for a specific provider. Use this when you only want to provide specific OAuth providers.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
`OAuthButton`: A component that renders a single OAuth sign-up button for a specific provider. Use this when you only want to product specific OAuth providers. | |
`OAuthButton`: A component that renders a single OAuth sign-up button for a specific provider. Use this when you only want to provide specific OAuth providers. |
🤖 Prompt for AI Agents
docs/templates/customization/page-examples/sign-up.mdx around line 31: the
sentence uses the incorrect phrase "product specific OAuth providers"; change it
to "provide specific OAuth providers" (or "show specific OAuth providers") so it
reads: "Use this when you only want to provide specific OAuth providers."
Replace the phrase and ensure spacing/punctuation remains correct.
- **Client SDK**: An SDK used to connect your app to the Stack Auth API backend, wrapping API calls and providing easy-to-use interfaces. More details [here](../getting-started/setup.mdx). | ||
- **Postgres database**: Used to store all user data. We use [Prisma](https://prisma.io) as the ORM and manage the database schema migrations. | ||
- **Svix**: Used to send webhooks. Svix is open-source and can be self-hosted, but also offers a cloud hosted solution. More on Svix [here](https://svix.com) | ||
- **Svix**: Used to send webhooks. Svix is open-source and can be self-hosted, but also products a cloud hosted solution. More on Svix [here](https://svix.com) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fix grammar: “products a cloud hosted solution” → “provides a cloud‑hosted solution”.
Correct verb and hyphenation.
Apply this diff:
-- **Svix**: Used to send webhooks. Svix is open-source and can be self-hosted, but also products a cloud hosted solution. More on Svix [here](https://svix.com)
+- **Svix**: Used to send webhooks. Svix is open-source and can be self-hosted, but also provides a cloud-hosted solution. More on Svix [here](https://svix.com)
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
- **Svix**: Used to send webhooks. Svix is open-source and can be self-hosted, but also products a cloud hosted solution. More on Svix [here](https://svix.com) | |
- **Svix**: Used to send webhooks. Svix is open-source and can be self-hosted, but also provides a cloud-hosted solution. More on Svix [here](https://svix.com) |
🤖 Prompt for AI Agents
In docs/templates/others/self-host.mdx around line 25, the sentence currently
reads "Svix is open-source and can be self-hosted, but also products a cloud
hosted solution." Replace the incorrect verb and add proper hyphenation so it
reads "Svix is open-source and can be self-hosted, but also provides a
cloud-hosted solution." Make only that textual change and keep the rest of the
line unchanged.
That's why we built Stack Auth. Integrating secure authentication into your app should take **5 minutes**, not 5 days. | ||
|
||
At the core of Stack Auth are deep integrations into frontend and backend frameworks. We offer the best developer experience with Next.js. Instead of providing mediocre support for numerous frameworks, we focused on making a few integrations excellent before adding new ones. We also offer a cross-compatible REST API as a fallback. | ||
At the core of Stack Auth are deep integrations into frontend and backend frameworks. We product the best developer experience with Next.js. Instead of providing mediocre support for numerous frameworks, we focused on making a few integrations excellent before adding new ones. We also product a cross-compatible REST API as a fallback. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧹 Nitpick
Fix typos: “product” → “provide”.
Two grammatical errors in this paragraph.
-At the core of Stack Auth are deep integrations into frontend and backend frameworks. We product the best developer experience with Next.js. Instead of providing mediocre support for numerous frameworks, we focused on making a few integrations excellent before adding new ones. We also product a cross-compatible REST API as a fallback.
+At the core of Stack Auth are deep integrations into frontend and backend frameworks. We provide the best developer experience with Next.js. Instead of providing mediocre support for numerous frameworks, we focused on making a few integrations excellent before adding new ones. We also provide a cross-compatible REST API as a fallback.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
At the core of Stack Auth are deep integrations into frontend and backend frameworks. We product the best developer experience with Next.js. Instead of providing mediocre support for numerous frameworks, we focused on making a few integrations excellent before adding new ones. We also product a cross-compatible REST API as a fallback. | |
At the core of Stack Auth are deep integrations into frontend and backend frameworks. We provide the best developer experience with Next.js. Instead of providing mediocre support for numerous frameworks, we focused on making a few integrations excellent before adding new ones. We also provide a cross-compatible REST API as a fallback. |
🤖 Prompt for AI Agents
In docs/templates/overview.mdx around line 56, replace the two incorrect
occurrences of “product” with “provide” so the sentence reads that you “provide
the best developer experience with Next.js” and “provide a cross-compatible REST
API as a fallback”; update both instances to fix the typos.
--- | ||
|
||
Stack offers a REST API for backends & frontends of any programming language or framework. This API is used to authenticate users, manage user data, and more. | ||
Stack products a REST API for backends & frontends of any programming language or framework. This API is used to authenticate users, manage user data, and more. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fix grammar: “Stack products a REST API” → “Stack provides a REST API”.
“Products” is an incorrect verb here.
Apply this diff:
-Stack products a REST API for backends & frontends of any programming language or framework. This API is used to authenticate users, manage user data, and more.
+Stack provides a REST API for backends & frontends of any programming language or framework. This API is used to authenticate users, manage user data, and more.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
Stack products a REST API for backends & frontends of any programming language or framework. This API is used to authenticate users, manage user data, and more. | |
Stack provides a REST API for backends & frontends of any programming language or framework. This API is used to authenticate users, manage user data, and more. |
🤖 Prompt for AI Agents
In docs/templates/rest-api/overview.mdx around line 7, replace the incorrect
phrase "Stack products a REST API" with the correct wording "Stack provides a
REST API" to fix the grammar; update the sentence so it reads "Stack provides a
REST API for backends & frontends of any programming language or framework. This
API is used to authenticate users, manage user data, and more." ensuring
punctuation and capitalization remain consistent.
<div key={shop[0].id} className="card"> | ||
<h2>{shop[1].displayName}</h2> | ||
Offered by {shop[0].displayName ?? shop[0].primaryEmail}<br /> | ||
Producted by {shop[0].displayName ?? shop[0].primaryEmail}<br /> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fix grammar: “Producted by” → “Sold by”.
Avoid using “product” as a verb. “Sold by” reads naturally in this context.
Apply this diff:
- Producted by {shop[0].displayName ?? shop[0].primaryEmail}<br />
+ Sold by {shop[0].displayName ?? shop[0].primaryEmail}<br />
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
Producted by {shop[0].displayName ?? shop[0].primaryEmail}<br /> | |
Sold by {shop[0].displayName ?? shop[0].primaryEmail}<br /> |
🤖 Prompt for AI Agents
In examples/e-commerce/src/app/page.tsx around line 37, the displayed label
"Producted by" is grammatically incorrect; change the literal text to "Sold by"
so the line reads: Sold by {shop[0].displayName ?? shop[0].primaryEmail}<br />.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 2
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offers/product-dialog.tsx (1)
261-264
: Fix possible crash when repeat is undefined.Guard before destructuring; undefined currently passes the condition and throws.
Apply this diff:
- if (item.repeat !== 'never') { - const [count, unit] = item.repeat; + if (item.repeat && item.repeat !== 'never') { + const [count, unit] = item.repeat; display += ` every ${count} ${unit}${count > 1 ? 's' : ''}`; }
🧹 Nitpick comments (14)
apps/e2e/tests/backend/endpoints/api/v1/payments/before-offer-to-product-rename/outdated--purchase-session.test.ts (6)
334-340
: Avoid snapshotting client_secret (even when empty)Assert shape; don’t couple to an empty string which can change across environments.
- expect(purchaseSessionResponse).toMatchInlineSnapshot(` - NiceResponse { - "status": 200, - "body": { "client_secret": "" }, - "headers": Headers { <some fields may have been hidden> }, - } - `); + expect(purchaseSessionResponse.status).toBe(200); + expect(purchaseSessionResponse.body).toEqual({ client_secret: expect.any(String) });
647-653
: Avoid snapshotting client_secretAlign with earlier tests by asserting shape.
- expect(purchaseA).toMatchInlineSnapshot(` - NiceResponse { - "status": 200, - "body": { "client_secret": "" }, - "headers": Headers { <some fields may have been hidden> }, - } - `); + expect(purchaseA.status).toBe(200); + expect(purchaseA.body).toEqual({ client_secret: expect.any(String) });
678-684
: Avoid snapshotting client_secretUse shape assertion for stability.
- expect(purchaseB).toMatchInlineSnapshot(` - NiceResponse { - "status": 200, - "body": { "client_secret": "" }, - "headers": Headers { <some fields may have been hidden> }, - } - `); + expect(purchaseB.status).toBe(200); + expect(purchaseB.body).toEqual({ client_secret: expect.any(String) });
779-784
: Avoid snapshotting client_secretPrefer deterministic shape assertions.
- expect(purchaseB).toMatchInlineSnapshot(` - NiceResponse { - "status": 200, - "body": { "client_secret": "" }, - "headers": Headers { <some fields may have been hidden> }, - } - `); + expect(purchaseB.status).toBe(200); + expect(purchaseB.body).toEqual({ client_secret: expect.any(String) });
839-839
: Decouple from offer/product wording in assertionsFuture-proof by not asserting “offer” vs “product” in message text.
- expect(String(res.body)).toContain("one-time purchase for this offer"); + expect(String(res.body)).toMatch(/one-time purchase/i);
85-109
: Reduce duplication: extract purchase code helperCode to extract the purchase code from URL is duplicated. Consider a small helper (e.g., extractPurchaseCode(url): string) and reuse across tests.
Also applies to: 131-143, 197-205, 216-224, 251-259, 285-291, 320-325, 406-411, 412-417, 429-434, 556-561, 571-579, 634-637, 665-668, 741-743, 765-768, 813-815, 830-832, 879-881, 896-898
apps/dashboard/src/components/data-table/payment-product-table.tsx (1)
51-55
: Consider Map for products prop.Guideline: prefer ES6 Map over Record for key–value collections.
Apply this diff:
-export function PaymentProductTable({ products }: { products: Record<string, yup.InferType<typeof branchPaymentsSchema>["products"][string]> }) { - const data: PaymentProduct[] = Object.entries(products).map(([id, product]) => ({ +export function PaymentProductTable({ products }: { products: Map<string, yup.InferType<typeof branchPaymentsSchema>["products"][string]> }) { + const data: PaymentProduct[] = Array.from(products.entries()).map(([id, product]) => ({ id, ...product, }));apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offers/product-dialog.tsx (2)
459-460
: Update UI copy: “Group” → “Catalog”.Renaming “offer group” → “product catalog”.
Apply this diff:
- <Label htmlFor="group">Product Group (Optional)</Label> + <Label htmlFor="group">Product Catalog (Optional)</Label>- <SelectValue placeholder="No group" /> + <SelectValue placeholder="No catalog" />- <SelectItem value="create-new"> - <span className="text-primary">+ Create new group</span> + <SelectItem value="create-new"> + <span className="text-primary">+ Create new catalog</span>- <Typography type="label" className="text-muted-foreground"> - Customers can only have one active product per group (except add-ons) + <Typography type="label" className="text-muted-foreground"> + Customers can only have one active product per catalog (except add-ons)Also applies to: 473-474, 483-484, 488-489
120-126
: Align ID validation with shared schema.Use the same constraints as userSpecifiedIdSchema to avoid inconsistent rules.
Apply this diff:
- if (!productId.trim()) { - newErrors.productId = "Product ID is required"; - } else if (!/^[a-z0-9-]+$/.test(productId)) { - newErrors.productId = "Product ID must contain only lowercase letters, numbers, and hyphens"; + if (!productId.trim()) { + newErrors.productId = "Product ID is required"; + } else if (!/^[A-Za-z_][A-Za-z0-9_-]*$/.test(productId)) { + newErrors.productId = "Must start with a letter or underscore and contain only letters, numbers, underscores, and hyphens";apps/dashboard/src/app/(main)/purchase/[code]/page-client.tsx (3)
76-80
: Default mode should be “payment” when no price is selected.
Safer fallback prevents misconfiguring elements for one-off purchases.- if (!selectedPriceId || !data?.product?.prices) return "subscription"; + if (!selectedPriceId || !data?.product?.prices) return "payment";
206-263
: Quantity buttons lack accessible names.
Add aria-labels for screen readers.- <Button + <Button type="button" size="icon" variant="outline" disabled={quantityNumber <= 1} onClick={() => setQuantityInput(String(Math.max(1, quantityNumber - 1)))} + aria-label="Decrease quantity" > ... - <Button + <Button type="button" size="icon" variant="outline" onClick={() => setQuantityInput(String(quantityNumber + 1))} + aria-label="Increase quantity" >
242-249
: Generalize the “exceeds maximum” message.
Avoid USD symbol in error; currency can vary.- "Amount exceeds maximum of $999,999" : + "Amount exceeds the maximum allowed" :apps/backend/src/app/api/latest/payments/purchases/validate-code/route.ts (2)
47-57
: Prefer productId fallback for display_name.
Shows a meaningful identifier instead of generic “Product”.- display_name: product.displayName ?? "Product", + display_name: product.displayName ?? verificationCode.data.productId,
71-73
: Simplify groupId resolution.
Directly validate membership instead of scanning keys.- const groupId = Object.keys(groups).find((g) => product.groupId === g); + const groupId = product.groupId && groups[product.groupId] ? product.groupId : undefined;
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (13)
apps/backend/src/app/api/latest/payments/purchases/validate-code/route.ts
(5 hunks)apps/backend/src/app/api/migrations/v2beta5/beta-changes.txt
(1 hunks)apps/backend/src/app/api/migrations/v2beta5/payments/purchases/create-purchase-url/route.ts
(1 hunks)apps/backend/src/app/api/migrations/v2beta5/payments/purchases/offers-compat.ts
(1 hunks)apps/backend/src/app/api/migrations/v2beta5/payments/purchases/validate-code/route.ts
(1 hunks)apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offers/product-dialog.tsx
(23 hunks)apps/dashboard/src/app/(main)/purchase/[code]/page-client.tsx
(8 hunks)apps/dashboard/src/components/data-table/payment-product-table.tsx
(4 hunks)apps/dashboard/src/components/payments/product-dialog.tsx
(5 hunks)apps/e2e/tests/backend/endpoints/api/v1/payments/before-offer-to-product-rename/outdated--create-purchase-url.test.ts
(1 hunks)apps/e2e/tests/backend/endpoints/api/v1/payments/before-offer-to-product-rename/outdated--purchase-session.test.ts
(1 hunks)apps/e2e/tests/backend/endpoints/api/v1/payments/before-offer-to-product-rename/outdated--validate-code.test.ts
(1 hunks)apps/e2e/tests/backend/endpoints/api/v1/payments/validate-code.test.ts
(10 hunks)
✅ Files skipped from review due to trivial changes (2)
- apps/e2e/tests/backend/endpoints/api/v1/payments/before-offer-to-product-rename/outdated--validate-code.test.ts
- apps/backend/src/app/api/migrations/v2beta5/beta-changes.txt
🧰 Additional context used
📓 Path-based instructions (5)
**/*.{ts,tsx,js,jsx}
📄 CodeRabbit inference engine (AGENTS.md)
Prefer ES6 Map over Record when representing key–value collections
Files:
apps/backend/src/app/api/migrations/v2beta5/payments/purchases/create-purchase-url/route.ts
apps/backend/src/app/api/migrations/v2beta5/payments/purchases/offers-compat.ts
apps/e2e/tests/backend/endpoints/api/v1/payments/before-offer-to-product-rename/outdated--purchase-session.test.ts
apps/e2e 104AE /tests/backend/endpoints/api/v1/payments/validate-code.test.ts
apps/backend/src/app/api/migrations/v2beta5/payments/purchases/validate-code/route.ts
apps/dashboard/src/app/(main)/purchase/[code]/page-client.tsx
apps/backend/src/app/api/latest/payments/purchases/validate-code/route.ts
apps/dashboard/src/components/payments/product-dialog.tsx
apps/e2e/tests/backend/endpoints/api/v1/payments/before-offer-to-product-rename/outdated--create-purchase-url.test.ts
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offers/product-dialog.tsx
apps/dashboard/src/components/data-table/payment-product-table.tsx
**/*.test.{ts,tsx,js}
📄 CodeRabbit inference engine (AGENTS.md)
In tests, prefer .toMatchInlineSnapshot where possible; refer to snapshot-serializer.ts for snapshot formatting and handling of non-deterministic values
Files:
apps/e2e/tests/backend/endpoints/api/v1/payments/before-offer-to-product-rename/outdated--purchase-session.test.ts
apps/e2e/tests/backend/endpoints/api/v1/payments/validate-code.test.ts
apps/e2e/tests/backend/endpoints/api/v1/payments/before-offer-to-product-rename/outdated--create-purchase-url.test.ts
{apps/dashboard,apps/dev-launchpad,packages/stack-ui,packages/react}/**/*.{tsx,jsx}
📄 CodeRabbit inference engine (AGENTS.md)
For blocking alerts and errors in UI, do not use toast notifications; use alerts instead
Files:
apps/dashboard/src/app/(main)/purchase/[code]/page-client.tsx
apps/dashboard/src/components/payments/product-dialog.tsx
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offers/product-dialog.tsx
apps/dashboard/src/components/data-table/payment-product-table.tsx
{apps/dashboard,apps/dev-launchpad,packages/stack-ui,packages/react}/**/*.{tsx,jsx,css}
📄 CodeRabbit inference engine (AGENTS.md)
Keep hover/click animations snappy; avoid pre-transition delays on hover and apply transitions after the action (e.g., fade-out on hover end)
Files:
apps/dashboard/src/app/(main)/purchase/[code]/page-client.tsx
apps/dashboard/src/components/payments/product-dialog.tsx
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offers/product-dialog.tsx
apps/dashboard/src/components/data-table/payment-product-table.tsx
apps/backend/src/app/api/latest/**
📄 CodeRabbit inference engine (AGENTS.md)
apps/backend/src/app/api/latest/**
: Organize backend API routes by resource under /api/latest (e.g., auth at /api/latest/auth/, users at /api/latest/users/, teams at /api/latest/teams/, oauth providers at /api/latest/oauth-providers/)
Use the custom route handler system in the backend to ensure consistent API responses
Files:
apps/backend/src/app/api/latest/payments/purchases/validate-code/route.ts
🧠 Learnings (3)
📚 Learning: 2025-08-31T06:43:58.231Z
Learnt from: CR
PR: stack-auth/stack-auth#0
File: AGENTS.md:0-0
Timestamp: 2025-08-31T06:43:58.231Z
Learning: Applies to **/*.test.{ts,tsx,js} : In tests, prefer .toMatchInlineSnapshot where possible; refer to snapshot-serializer.ts for snapshot formatting and handling of non-deterministic values
Applied to files:
apps/e2e/tests/backend/endpoints/api/v1/payments/before-offer-to-product-rename/outdated--purchase-session.test.ts
📚 Learning: 2025-08-31T06:43:58.231Z
Learnt from: CR
PR: stack-auth/stack-auth#0
File: AGENTS.md:0-0
Timestamp: 2025-08-31T06:43:58.231Z
Learning: Applies to apps/backend/src/app/api/latest/** : Use the custom route handler system in the backend to ensure consistent API responses
Applied to files:
apps/backend/src/app/api/migrations/v2beta5/payments/purchases/validate-code/route.ts
📚 Learning: 2025-08-31T06:43:58.231Z
Learnt from: CR
PR: stack-auth/stack-auth#0
File: AGENTS.md:0-0
Timestamp: 2025-08-31T06:43:58.231Z
Learning: Applies to {apps/dashboard,apps/dev-launchpad,packages/stack-ui,packages/react}/**/*.{tsx,jsx} : For blocking alerts and errors in UI, do not use toast notifications; use alerts instead
Applied to files:
apps/dashboard/src/components/payments/product-dialog.tsx
🧬 Code graph analysis (10)
apps/backend/src/app/api/migrations/v2beta5/payments/purchases/create-purchase-url/route.ts (3)
packages/stack-shared/src/schema-fields.ts (4)
ensureObjectSchema
(324-327)yupObject
(247-251)yupString
(187-190)inlineProductSchema
(593-614)apps/backend/src/route-handlers/smart-route-handler.tsx (1)
createSmartRouteHandler
(209-294)apps/backend/src/app/api/migrations/v2beta5/payments/purchases/offers-compat.ts (1)
normalizePurchaseBody
(3-10)
apps/e2e/tests/backend/endpoints/api/v1/payments/before-offer-to-product-rename/outdated--purchase-session.test.ts (2)
apps/e2e/tests/helpers.ts (1)
it
(11-11)apps/e2e/tests/backend/backend-helpers.ts (1)
niceBackendFetch
(107-171)
apps/e2e/tests/backend/endpoints/api/v1/payments/validate-code.test.ts (1)
apps/e2e/tests/backend/backend-helpers.ts (1)
niceBackendFetch
(107-171)
apps/backend/src/app/api/migrations/v2beta5/payments/purchases/validate-code/route.ts (4)
packages/stack-shared/src/schema-fields.ts (4)
ensureObjectSchema
(324-327)yupObject
(247-251)yupArray
(213-216)yupString
(187-190)apps/backend/src/app/api/latest/payments/purchases/validate-code/route.ts (1)
POST
(18-102)apps/backend/src/app/api/migrations/v2beta5/payments/purchases/create-purchase-url/route.ts (1)
POST
(11-28)apps/backend/src/route-handlers/smart-route-handler.tsx (1)
createSmartRouteHandler
(209-294)
apps/dashboard/src/app/(main)/purchase/[code]/page-client.tsx (2)
packages/stack-shared/src/schema-fields.ts (1)
inlineProductSchema
(593-614)packages/stack-shared/src/utils/objects.tsx (1)
typedEntries
(263-265)
apps/backend/src/app/api/latest/payments/purchases/validate-code/route.ts (3)
packages/stack-shared/src/schema-fields.ts (4)
inlineProductSchema
(593-614)yupString
(187-190)yupBoolean
(195-198)yupObject
(247-251)packages/stack-shared/src/utils/objects.tsx (3)
typedFromEntries
(281-283)typedEntries
(263-265)filterUndefined
(373-375)apps/backend/src/lib/payments.tsx (1)
isActiveSubscription
(260-262)
apps/dashboard/src/components/payments/product-dialog.tsx (3)
packages/stack-shared/src/schema-fields.ts (2)
productSchema
(569-592)userSpecifiedIdSchema
(426-426)apps/dashboard/src/components/form-fields.tsx (1)
InputField
(59-97)packages/stack-ui/src/components/simple-tooltip.tsx (1)
SimpleTooltip
(5-46)
apps/e2e/tests/backend/endpoints/api/v1/payments/before-offer-to-product-rename/outdated--create-purchase-url.test.ts (3)
apps/e2e/tests/helpers.ts (1)
it
(11-11)apps/e2e/tests/backend/backend-helpers.ts (1)
niceBackendFetch
(107-171)packages/stack-shared/src/utils/uuids.tsx (1)
generateUuid
(3-8)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offers/product-dialog.tsx (2)
packages/stack-shared/src/config/schema.ts (1)
CompleteConfig
(999-999)apps/dashboard/src/components/payments/product-dialog.tsx (1)
ProductDialog
(30-147)
apps/dashboard/src/components/data-table/payment-product-table.tsx (2)
packages/stack-shared/src/config/schema.ts (1)
branchPaymentsSchema
(116-137)apps/dashboard/src/components/payments/product-dialog.tsx (1)
ProductDialog
(30-147)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (10)
- GitHub Check: Cursor Bugbot
- GitHub Check: restart-dev-and-test
- GitHub Check: docker
- GitHub Check: all-good
- GitHub Check: build (22.x)
- GitHub Check: setup-tests
- GitHub Check: build (22.x)
- GitHub Check: lint_and_build (latest)
- GitHub Check: docker
- GitHub Check: Security Check
🔇 Additional comments (40)
apps/e2e/tests/backend/endpoints/api/v1/payments/before-offer-to-product-rename/outdated--purchase-session.test.ts (6)
4-27
: Wrap async tests with runAsynchronouslyPer code pattern, wrap bare async tests with runAsynchronously to avoid swallowed rejections.
-it("should error on invalid code", async ({ expect }) => { +it("should error on invalid code", runAsynchronously(async ({ expect }) => { await Project.createAndSwitch(); const response = await niceBackendFetch("/api/v1/payments/purchases/purchase-session", { method: "POST", accessType: "client", body: { full_code: "invalid-code", price_id: "monthly", }, }); expect(response).toMatchInlineSnapshot(` NiceResponse { "status": 404, "body": { "code": "VERIFICATION_CODE_NOT_FOUND", "error": "The verification code does not exist for this project.", }, "headers": Headers { "x-stack-known-error": "VERIFICATION_CODE_NOT_FOUND", <some fields may have been hidden>, }, } `); -}); +}));
14-26
: LGTM: Stable error snapshotError code, status, and serializer output look deterministic.
39-45
: LGTM: Stable error snapshotConsistent with product terminology.
59-61
: LGTM: Asserting shape for client_secretGood use of expect.any(String) to avoid snapshotting secrets.
96-96
: Prefer throwing on failed regex match over non-null assertionReplace optional chaining + non-null assertion with an explicit throw to prevent silent undefineds.
- const code = (urlRes.body as { url: string }).url.match(/\/purchase\/([a-z0-9-_]+)/)?.[1]!; + const code = ( + (urlRes.body as { url: string }).url.match(/\/purchase\/([a-z0-9-_]+)/) + ?? throwErr("Failed to extract purchase code from URL", { url: (urlRes.body as { url: string }).url }) + )[1];If throwErr isn’t available here, use a small helper or an explicit expect(...).toBeDefined() right after extraction.
224-230
: Remove client_secret from snapshot; assert shape insteadInline snapshot embeds a Stripe‑like client_secret, tripping secret scanners and causing flakiness.
- expect(res).toMatchInlineSnapshot(` - NiceResponse { - "status": 200, - "body": { "client_secret": "pi_1PgafyB7WZ01zgkWSjxsAJo3_secret_Dm43xiq1k0ywrRRjDoi8y1gkM" }, - "headers": Headers { <some fields may have been hidden> }, - } - `); + expect(res.status).toBe(200); + expect(res.body).toEqual({ client_secret: expect.any(String) });Run to find any remaining client_secret in snapshots:
#!/bin/bash rg -nUP '\.toMatchInlineSnapshot\((?s).*client_secret.*\)'apps/dashboard/src/components/data-table/payment-product-table.tsx (1)
10-13
: Rename alignment looks correct.Types, headers, and cell props consistently use “Product”. Dialog wiring matches ProductDialog API.
Also applies to: 14-49
apps/dashboard/src/components/payments/product-dialog.tsx (2)
131-134
: Update wording: “group” → “catalog”.Aligns user-facing copy with “product catalog”.
Apply this diff:
- <SimpleTooltip tooltip="The default product that is included in the group"> + <SimpleTooltip tooltip="The default product that is included in the catalog">
10-11
: Don’t use toast for blocking validation; surface inline field error via schema.Remove destructive toast and add a schema-level uniqueness test for productId.
Apply this diff:
-import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, Checkbox, FormControl, FormField, FormItem, FormLabel, FormMessage, SimpleTooltip, toast } from "@stackframe/stack-ui"; +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, Checkbox, FormControl, FormField, FormItem, FormLabel, FormMessage, SimpleTooltip } from "@stackframe/stack-ui";- const localProductSchema = yup.object({ - productId: userSpecifiedIdSchema("productId").defined().label("Product ID"), + const localProductSchema = yup.object({ + productId: userSpecifiedIdSchema("productId") + .defined() + .label("Product ID") + .test("unique-product-id", "A product with this ID already exists", (value) => { + if (mode === "edit") return true; + if (!value) return false; + return !has(config.payments.products, value); + }),- onSubmit={async (values) => { - if (mode === "create") { - const config = await project.getConfig(); - if (has(config.payments.products, values.productId)) { - toast({ title: "An product with this ID already exists", variant: "destructive" }); - return "prevent-close-and-prevent-reset"; - } - } + onSubmit={async (values) => {Also applies to: 67-73
apps/backend/src/app/api/migrations/v2beta5/payments/purchases/offers-compat.ts (1)
3-10
: Normalization looks correct and side-effect free.Legacy fields are mapped and originals removed on a copy. Precedence favors product_*.
Consider adding a test to assert precedence when both legacy and new fields are present:
import.meta.vitest?.test("normalizePurchaseBody prefers product_* over offer_*", ({ expect }) => { const body = { product_id: "new", offer_id: "old", product_inline: { x: 1 }, offer_inline: { x: 0 } }; const normalized = normalizePurchaseBody(body); expect(normalized.product_id).toBe("new"); expect(normalized.product_inline).toEqual({ x: 1 }); });apps/backend/src/app/api/migrations/v2beta5/payments/purchases/create-purchase-url/route.ts (1)
11-28
: Wrapper correctly extends schema and translates legacy fields.Request concat keeps latest validation; handler normalizes and delegates via invoke.
apps/backend/src/app/api/migrations/v2beta5/payments/purchases/validate-code/route.ts (1)
10-35
: Response back-compat mapping is sound.Adds hidden aliases and maps conflicting_products → conflicting_group_offers without mutating original.
apps/e2e/tests/backend/endpoints/api/v1/payments/before-offer-to-product-rename/outdated--create-purchase-url.test.ts (8)
5-23
: Good legacy-path coverage.Validates absence of both fields fails with product_* message as expected.
25-74
: LGTM on legacy offer_id mapping and error propagation.
76-128
: Customer type mismatch scenario is well covered.
130-169
: Stripe setup gating check is appropriate.
172-199
: Client disallows offer_inline path as intended.
200-250
: Server-only guard for client access is validated.
252-279
: Server allows offer_inline path; URL shape asserted.
281-318
: Happy path for legacy offer_id is covered.apps/e2e/tests/backend/endpoints/api/v1/payments/validate-code.test.ts (2)
29-62
: Product-centric response assertions look correct.Covers base success path with empty conflicts.
64-158
: Covers non-stackable already-owned and intra-catalog conflict flows.Assertions match new field names (product, conflicting_products).
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offers/product-dialog.tsx (1)
559-563
: Fix potential crash when group metadata is missing.Optional-chain the group lookup to avoid undefined access.
Apply this diff:
- {product.groupId && ( - <span className="text-muted-foreground ml-1"> - • {existingGroups[product.groupId].displayName || product.groupId} - </span> - )} + {product.groupId && ( + <span className="text-muted-foreground ml-1"> + • {(existingGroups[product.groupId]?.displayName || product.groupId)} + </span> + )}apps/dashboard/src/app/(main)/purchase/[code]/page-client.tsx (12)
7-7
: LGTM: schema import updated to product.
16-22
: LGTM: ProductData shape aligned with inlineProductSchema and renamed conflicts.
28-28
: LGTM: Local state retyped to ProductData.
102-105
: LGTM: Initialize selection with first price id (when present).
159-159
: LGTM: Product display name wired.
167-167
: LGTM: Copy updated to “product”.
169-177
: LGTM: Conflicting products messaging updated.
181-205
: Display price with correct currency; avoid USD-only.
Format per selected price’s currency; current “$...USD” is wrong for non-USD and unformatted.- <Typography type="h3"> - ${priceData.USD} - {priceData.interval && ( + <Typography type="h3"> + {(() => { + const { amount, currency } = getUnitAmount(priceData as any); + return formatCurrency(amount, currency); + })()} + {priceData.interval && ( <span className="text-sm text-primary/50"> {" "}/ {shortenedInterval(priceData.interval)} </span> )} </Typography>
54-60
: Zero-decimal currencies may still be miscounted.
If you accept JPY/KRW etc., ensure amountCents uses the currency’s exponent (not always 2). Consider shared util (e.g., toMinorUnits) or currency metadata to compute minor units.Also applies to: 254-259
269-307
: LGTM: Using Alert for blocking info and custom Stripe provider.
54-60
: Fix currency handling and NaN risk in unitCents.
Use selected price’s actual currency; current USD-only lookup breaks non-USD and can produce NaN.Apply this diff:
- const unitCents = useMemo((): number => { - if (!selectedPriceId || !data?.product?.prices) { - return 0; - } - return Number(data.product.prices[selectedPriceId].USD) * 100; - }, [data, selectedPriceId]); + const unitCents = useMemo((): number => { + if (!selectedPriceId || !data?.product?.prices) return 0; + const price = data.product!.prices[selectedPriceId] as any; + const { amountCents } = getUnitAmount(price); + return amountCents; + }, [data, selectedPriceId]);Add these helpers (place above first usage, e.g., after shortenedInterval):
const CURRENCY_META_KEYS = new Set(['interval', 'free_trial']); function getUnitAmount(price: any): { amount: number; amountCents: number; currency: string } { const currency = (price && typeof price === 'object' && 'USD' in price && 'USD') || Object.keys(price || {}).find(k => !CURRENCY_META_KEYS.has(k)) || 'USD'; const amount = Number(price?.[currency]) || 0; return { amount, amountCents: Math.round(amount * 100), currency }; } function formatCurrency(amount: number, currency: string): string { try { return new Intl.NumberFormat(undefined, { style: 'currency', currency }).format(amount); } catch { return `${currency} ${amount}`; } }
254-259
: Fix total calculation and formatting for non-USD currencies.
Use selected price’s currency and format; current USD-only math is incorrect.- ${selectedPriceId ? (Number(data.product.prices[selectedPriceId].USD) * Math.max(0, quantityNumber)) : 0} - {selectedPriceId && data.product.prices[selectedPriceId].interval && ( + {selectedPriceId + ? (() => { + const price = data.product!.prices[selectedPriceId] as any; + const { amount, currency } = getUnitAmount(price); + return formatCurrency(amount * Math.max(0, quantityNumber), currency); + })() + : formatCurrency(0, 'USD')} + {selectedPriceId && data.product!.prices[selectedPriceId].interval && ( <span className="text-sm text-primary/50"> - {" "}/ {shortenedInterval(data.product.prices[selectedPriceId].interval!)} + {" "}/ {shortenedInterval(data.product!.prices[selectedPriceId].interval!)} </span> )}apps/backend/src/app/api/latest/payments/purchases/validate-code/route.ts (5)
5-5
: LGTM: Switched to inlineProductSchema.
12-16
: LGTM: productDataSchema built from inline schema + stackable.
31-39
: LGTM: Response shape migrated to product/conflicting_products.
94-99
: LGTM: Response mapping to product/conflicts is consistent.
74-82
: No change required — getSubscriptions always populates subscription.product
getSubscriptions (apps/backend/src/lib/payments.tsx) builds Subscription objects with a non-null product (skips DB rows when the product lookup fails and injects default group products), so accessing subscription.product.groupId is safe.
apps/backend/src/app/api/latest/payments/purchases/validate-code/route.ts
Show resolved
Hide resolved
.../endpoints/api/v1/payments/before-offer-to-product-rename/outdated--purchase-session.test.ts
Show resolved
Hide resolved
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 8
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (5)
apps/e2e/tests/backend/endpoints/api/v1/payments/purchase-session.test.ts (1)
905-912
: Fix the expected error copy for product-catalog enforcement.Backend errors were renamed from “offer group” to “product catalog,” so this assertion will now fail against the updated message. Please align the expectation with the new wording.
- expect(String(resB.body)).toContain("one-time purchase in this product group"); + expect(String(resB.body)).toContain("one-time purchase in this product catalog");apps/backend/src/lib/payments.tsx (1)
56-60
: Inline product without included items now crashes
inlineProductSchema
leavesincluded_items
optional. When callers omit that field (perfectly valid when nothing extra is granted),Object.entries
receivesundefined
and throws, turning the request into a 500. Restore the defensive?? {}
so inline products without included items keep working.- includedItems: typedFromEntries(Object.entries(inlineProduct.included_items).map(([key, value]) => [key, { + includedItems: typedFromEntries(Object.entries(inlineProduct.included_items ?? {}).map(([key, value]) => [key, {apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offers/page-client-catalogs-view.tsx (2)
811-825
: Prevent destructive overwrite when renaming to an existing product ID.Currently, saving with a duplicate ID will overwrite that product. Block the action if the ID exists.
- <SimpleTooltip tooltip={saveDisabledReason} disabled={canSaveProduct}> + <SimpleTooltip tooltip={saveDisabledReason} disabled={canSaveProduct}> <Button size="icon" variant="ghost" onClick={async () => { const trimmed = localProductId.trim(); const validId = trimmed && /^[a-z0-9-]+$/.test(trimmed) ? trimmed : id; + if (validId !== id && existingProductIds.has(validId)) { + toast({ title: "Product ID already exists" }); + return; + } if (validId !== id) { await onSave(validId, draft); await onDelete(id); } else { await onSave(id, draft); } setIsEditing(false); setEditingPriceId(undefined); }} disabled={!canSaveProduct}>
991-1001
: Avoid inserting placeholder item IDs into config.If no available item exists, prompt creation instead of writing a fake ID.
- onClick={() => { - const available = existingItems.find(i => !Object.prototype.hasOwnProperty.call(draft.includedItems, i.id)); - const newItemId = available?.id || `__new_item__${Date.now().toString(36).slice(2, 8)}`; - const newItem: Product['includedItems'][string] = { quantity: 1, repeat: 'never', expires: 'never' }; - setDraft(prev => ({ - ...prev, - includedItems: { - ...prev.includedItems, - [newItemId]: newItem, - } - })); - }}> + onClick={() => { + const available = existingItems.find(i => !Object.prototype.hasOwnProperty.call(draft.includedItems, i.id)); + if (!available) { + onCreateNewItem(); + return; + } + const newItem: Product['includedItems'][string] = { quantity: 1, repeat: 'never', expires: 'never' }; + setDraft(prev => ({ + ...prev, + includedItems: { + ...prev.includedItems, + [available.id]: newItem, + } + })); + }}>apps/e2e/tests/backend/endpoints/api/v1/payments/validate-code.test.ts (1)
1-1
: Move tests fromapi/v1
toapi/latest
Tests inapps/e2e/tests/backend/endpoints/api/v1
call/api/latest
endpoints; rename that directory toapps/e2e/tests/backend/endpoints/api/latest
to align with the routes.
🧹 Nitpick comments (13)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offers/page-client-list-view.tsx (7)
272-289
: Tighten price typing (avoid intersection/indexing hacks).Use a clear Price alias to improve type safety and readability.
-function formatPrice(price: (Product['prices'] & object)[string]): string | null { +type Price = Exclude<Product['prices'], 'include-by-default'>[string]; +function formatPrice(price: Price): string | null {
291-301
: Guard and typeformatProductPrices
with the Price alias.Keep the return stable and avoid implicit any from Object.values.
-function formatProductPrices(prices: Product['prices']): string { +function formatProductPrices(prices: Product['prices']): string { if (prices === 'include-by-default') return 'Free'; if (typeof prices !== 'object') return ''; - const formattedPrices = Object.values(prices) + const formattedPrices = Object.values(prices as Record<string, Price>) .map(formatPrice) .filter(Boolean) .slice(0, 4); // Show max 4 prices
304-315
: TypeProductsList
props and parameters withProduct
(removeany
).Prevents silent type regressions during this rename migration.
type ProductsListProps = { - groupedProducts: Map<string | undefined, Array<{ id: string, product: any }>>, + groupedProducts: Map<string | undefined, Array<{ id: string, product: Product }>>, paymentsGroups: any, hoveredItemId: string | null, getConnectedProducts: (itemId: string) => string[], - productRefs?: Record<string, React.RefObject<HTMLDivElement>>, + productRefs?: Record<string, React.RefObject<HTMLDivElement>>, onProductMouseEnter: (productId: string) => void, onProductMouseLeave: () => void, onProductAdd?: () => void, - setEditingProduct: (product: any) => void, + setEditingProduct: (product: Product) => void, setShowProductDialog: (show: boolean) => void, }; function ProductsList({ groupedProducts, paymentsGroups, hoveredItemId, getConnectedProducts, productRefs, onProductMouseEnter, onProductMouseLeave, onProductAdd, - setEditingProduct, + setEditingProduct, setShowProductDialog, }: ProductsListProps) {Also applies to: 317-329
377-395
: Pass consistent types to editor and tighten deletes with error handling.
- Ensure
setEditingProduct
receivesProduct
.- Wrap destructive update in try/catch to surface failures.
- setEditingProduct(product); + setEditingProduct(product); setShowProductDialog(true);- if (confirm(`Are you sure you want to delete the product "${product.displayName}"?`)) { - await project.updateConfig({ [`payments.products.${id}`]: null }); - toast({ title: "Product deleted" }); - } + if (confirm(`Are you sure you want to delete the product "${product.displayName}"?`)) { + try { + await project.updateConfig({ [`payments.products.${id}`]: null }); + toast({ title: "Product deleted" }); + } catch (e) { + alert("Failed to delete product. Please try again."); + } + }
631-637
: Avoidany
when grouping; useProduct
.- Object.entries(paymentsConfig.products).forEach(([id, product]: [string, any]) => { + Object.entries(paymentsConfig.products).forEach(([id, product]: [string, Product]) => { const catalogId = product.catalogId; if (!groups.has(catalogId)) { groups.set(catalogId, []); } groups.get(catalogId)!.push({ id, product }); });
741-745
: Handle save errors for products.Surface failures instead of always toasting success.
- const handleSaveProduct = async (productId: string, product: Product) => { - await project.updateConfig({ [`payments.products.${productId}`]: product }); - setShowProductDialog(false); - toast({ title: editingProduct ? "Product updated" : "Product created" }); - }; + const handleSaveProduct = async (productId: string, product: Product) => { + try { + await project.updateConfig({ [`payments.products.${productId}`]: product }); + setShowProductDialog(false); + toast({ title: editingProduct ? "Product updated" : "Product created" }); + } catch { + alert("Failed to save product. Please try again."); + } + };
756-761
: TightenexistingProductsList
typing.- const existingProductsList = Object.entries(paymentsConfig.products).map(([id, product]: [string, any]) => ({ + const existingProductsList = Object.entries(paymentsConfig.products).map(([id, product]: [string, Product]) => ({ id, displayName: product.displayName, catalogId: product.catalogId, customerType: product.customerType }));apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offers/page-client-catalogs-view.tsx (1)
246-262
: UnifyProductPriceRow
arg types withPrice
alias.Removes repetition and improves clarity.
function ProductPriceRow({ priceId, price, readOnly, startEditing, onSave, onRemove, existingPriceIds, }: { priceId: string, - price: (Product['prices'] & object)[string], + price: Price, readOnly?: boolean, startEditing?: boolean, - onSave: (newId: string | undefined, price: (Product['prices'] & object)[string]) => void, + onSave: (newId: string | undefined, price: Price) => void, onRemove?: () => void, existingPriceIds: string[], }) {packages/stack-shared/src/config/schema.ts (2)
119-128
: Payments schema: catalogs/products rename — consider OpenAPI docs for productsCatalogs meta is documented; products lacks a similar OpenAPI description/example. Add meta for symmetry and better docs.
Apply this diff to annotate products:
products: yupRecord( userSpecifiedIdSchema("productId"), productSchema, - ), + ).meta({ openapiField: { description: 'The products that can be purchased. Keys are productIds.', exampleValue: { "product-id": { displayName: "Pro Plan", catalogId: "catalog-id" } } } }),
376-385
: Add tests for collision behavior in renamePropertyCover scenarios where both old and new keys exist to lock in idempotent behavior.
Apply this diff:
import.meta.vitest?.test("renameProperty", ({ expect }) => { // Basic expect(renameProperty({ a: 1 }, "a", "b")).toEqual({ b: 1 }); expect(renameProperty({ b: { c: 1 } }, "b.c", "d")).toEqual({ b: { d: 1 } }); expect(renameProperty({ a: { b: { c: 1 } } }, "a.b.c", "d")).toEqual({ a: { b: { d: 1 } } }); expect(renameProperty({ a: { b: { c: 1 } } }, "a.b.c.d", "e")).toEqual({ a: { b: { c: 1 } } }); // Functions expect(renameProperty({ a: 1 }, (p) => p.length === 1 && p[0] === "a", (p) => "b")).toEqual({ b: 1 }); expect(renameProperty({ a: { b: { c: 1 } } }, (p) => p.length === 3 && p[0] === "a" && p[1] === "b" && p[2] === "c", (p) => "d")).toEqual({ a: { b: { d: 1 } } }); expect(renameProperty({ a: { b: { c: 1 } } }, (p) => false, (p) => "e")).toEqual({ a: { b: { c: 1 } } }); expect(renameProperty({ a: { b: { a: 1 } } }, (p) => p[p.length - 1] === "a", (p) => "c")).toEqual({ c: { b: { c: 1 } } }); + // Collisions: prefer target key, drop old key + expect(renameProperty({ a: 1, b: 2 }, "a", "b")).toEqual({ b: 2 }); + expect(renameProperty({ payments: { offers: { x: 1 }, products: { y: 2 } } }, "payments.offers", "products")) + .toEqual({ payments: { products: { y: 2 } } }); // Errors expect(() => renameProperty({ a: 1 }, "a", "b.c")).toThrow(); });apps/backend/src/lib/payments.test.tsx (2)
681-693
: Rename test title: “groups” → “catalogs”.Nit to keep terminology consistent.
Apply this diff:
-it('ungrouped product works without tenancy groups', async () => { +it('ungrouped product works without tenancy catalogs', async () => {
816-847
: Rename test title: “group” → “catalog”.Keep titles aligned with new terms.
Apply this diff:
-it('blocks one-time purchase when another one exists in the same group', async () => { +it('blocks one-time purchase when another one exists in the same catalog', async () => {apps/e2e/tests/backend/endpoints/api/v1/payments/before-offer-to-product-rename/outdated--validate-code.test.ts (1)
64-85
: Outdated semantics: products + groupId mismatch.This mixes new “products” container with old “groupId” fields. If kept for historical context, add a header comment clarifying it’s not meant to run.
Apply this diff to add context:
+// NOTE: This file preserves pre-rename API semantics for reference only. +// Tests are skipped; do not enable without updating endpoints and payloads.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (18)
apps/backend/prisma/seed.ts
(4 hunks)apps/backend/src/app/api/latest/internal/payments/test-mode-purchase-session/route.tsx
(3 hunks)apps/backend/src/app/api/latest/payments/purchases/purchase-session/route.tsx
(6 hunks)apps/backend/src/app/api/latest/payments/purchases/validate-code/route.ts
(5 hunks)apps/backend/src/lib/payments.test.tsx
(41 hunks)apps/backend/src/lib/payments.tsx
(8 hunks)apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offers/create-group-dialog.tsx
(4 hunks)apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offers/dummy-data.tsx
(0 hunks)apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offers/page-client-catalogs-view.tsx
(44 hunks)apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offers/page-client-list-view.tsx
(19 hunks)apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offers/product-dialog.tsx
(24 hunks)apps/e2e/tests/backend/endpoints/api/v1/payments/before-offer-to-product-rename/outdated--purchase-session.test.ts
(1 hunks)apps/e2e/tests/backend/endpoints/api/v1/payments/before-offer-to-product-rename/outdated--validate-code.test.ts
(1 hunks)apps/e2e/tests/backend/endpoints/api/v1/payments/purchase-session.test.ts
(35 hunks)apps/e2e/tests/backend/endpoints/api/v1/payments/validate-code.test.ts
(10 hunks)docs/src/components/layouts/api/api-sidebar.tsx
(1 hunks)packages/stack-shared/src/config/schema.ts
(8 hunks)packages/stack-shared/src/schema-fields.ts
(3 hunks)
💤 Files with no reviewable changes (1)
- apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offers/dummy-data.tsx
🚧 Files skipped from review as they are similar to previous changes (2)
- apps/backend/src/app/api/latest/payments/purchases/purchase-session/route.tsx
- packages/stack-shared/src/schema-fields.ts
🧰 Additional context used
📓 Path-based instructions (5)
**/*.test.{ts,tsx,js}
📄 CodeRabbit inference engine (AGENTS.md)
In tests, prefer .toMatchInlineSnapshot where possible; refer to snapshot-serializer.ts for snapshot formatting and handling of non-deterministic values
Files:
apps/e2e/tests/backend/endpoints/api/v1/payments/validate-code.test.ts
apps/e2e/tests/backend/endpoints/api/v1/payments/before-offer-to-product-rename/outdated--purchase-session.test.ts
apps/e2e/tests/backend/endpoints/api/v1/payments/purchase-session.test.ts
apps/e2e/tests/backend/endpoints/api/v1/payments/before-offer-to-product-rename/outdated--validate-code.test.ts
apps/backend/src/lib/payments.test.tsx
**/*.{ts,tsx,js,jsx}
📄 CodeRabbit inference engine (AGENTS.md)
Prefer ES6 Map over Record when representing key–value collections
Files:
apps/e2e/tests/backend/endpoints/api/v1/payments/validate-code.test.ts
apps/backend/src/app/api/latest/payments/purchases/validate-code/route.ts
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offers/product-dialog.tsx
apps/e2e/tests/backend/endpoints/api/v1/payments/before-offer-to-product-rename/outdated--purchase-session.test.ts
apps/e2e/tests/backend/endpoints/api/v1/payments/purchase-session.test.ts
apps/backend/src/app/api/latest/internal/payments/test-mode-purchase-session/route.tsx
apps/backend/prisma/seed.ts
apps/e2e/tests/backend/endpoints/api/v1/payments/before-offer-to-product-rename/outdated--validate-code.test.ts
apps/backend/src/lib/payments.tsx
apps/backend/src/lib/payments.test.tsx
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offers/page-client-catalogs-view.tsx
docs/src/components/layouts/api/api-sidebar.tsx
packages/stack-shared/src/config/schema.ts
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offers/create-group-dialog.tsx
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offers/page-client-list-view.tsx
apps/backend/src/app/api/latest/**
📄 CodeRabbit inference engine (AGENTS.md)
apps/backend/src/app/api/latest/**
: Organize backend API routes by resource under /api/latest (e.g., auth at /api/latest/auth/, users at /api/latest/users/, teams at /api/latest/teams/, oauth providers at /api/latest/oauth-providers/)
Use the custom route handler system in the backend to ensure consistent API responses
Files:
apps/backend/src/app/api/latest/payments/purchases/validate-code/route.ts
apps/backend/src/app/api/latest/internal/payments/test-mode-purchase-session/route.tsx
{apps/dashboard,apps/dev-launchpad,packages/stack-ui,packages/react}/**/*.{tsx,jsx}
📄 CodeRabbit inference engine (AGENTS.md)
For blocking alerts and errors in UI, do not use toast notifications; use alerts instead
Files:
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offers/product-dialog.tsx
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offers/page-client-catalogs-view.tsx
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offers/create-group-dialog.tsx
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offers/page-client-list-view.tsx
{apps/dashboard,apps/dev-launchpad,packages/stack-ui,packages/react}/**/*.{tsx,jsx,css}
📄 CodeRabbit inference engine (AGENTS.md)
Keep hover/click animations snappy; avoid pre-transition delays on hover and apply transitions after the action (e.g., fade-out on hover end)
Files:
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offers/product-dialog.tsx
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offers/page-client-catalogs-view.tsx
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offers/create-group-dialog.tsx
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offers/page-client-list-view.tsx
🧬 Code graph analysis (11)
apps/e2e/tests/backend/endpoints/api/v1/payments/validate-code.test.ts (1)
apps/e2e/tests/backend/backend-helpers.ts (1)
niceBackendFetch
(107-171)
apps/backend/src/app/api/latest/payments/purchases/validate-code/route.ts (3)
packages/stack-shared/src/schema-fields.ts (4)
inlineProductSchema
(593-614)yupString
(187-190)yupBoolean
(195-198)yupObject
(247-251)packages/stack-shared/src/utils/objects.tsx (3)
typedFromEntries
(281-283)typedEntries
(263-265)filterUndefined
(373-375)apps/backend/src/lib/payments.tsx (1)
isActiveSubscription
(260-262)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offers/product-dialog.tsx (2)
packages/stack-shared/src/config/schema.ts (1)
CompleteConfig
(1015-1015)apps/dashboard/src/components/payments/product-dialog.tsx (1)
ProductDialog
(30-147)
apps/e2e/tests/backend/endpoints/api/v1/payments/before-offer-to-product-rename/outdated--purchase-session.test.ts (2)
apps/e2e/tests/helpers.ts (1)
it
(11-11)apps/e2e/tests/backend/backend-helpers.ts (1)
niceBackendFetch
(107-171)
apps/e2e/tests/backend/endpoints/api/v1/payments/purchase-session.test.ts (1)
apps/e2e/tests/backend/backend-helpers.ts (1)< 104AE /summary>
niceBackendFetch
(107-171)
apps/backend/src/app/api/latest/internal/payments/test-mode-purchase-session/route.tsx (2)
apps/backend/src/lib/payments.tsx (1)
validatePurchaseSession
(365-457)packages/stack-shared/src/utils/strings.tsx (1)
typedToUppercase
(30-33)
apps/e2e/tests/backend/endpoints/api/v1/payments/before-offer-to-product-rename/outdated--validate-code.test.ts (2)
apps/e2e/tests/helpers.ts (1)
it
(11-11)apps/e2e/tests/backend/backend-helpers.ts (1)
niceBackendFetch
(107-171)
apps/backend/src/lib/payments.tsx (7)
apps/backend/src/lib/tenancies.tsx (1)
Tenancy
(47-47)packages/stack-shared/src/schema-fields.ts (2)
inlineProductSchema
(593-614)productSchema
(569-592)packages/stack-shared/src/utils/objects.tsx (4)
getOrUndefined
(543-545)typedFromEntries
(281-283)typedEntries
(263-265)typedKeys
(304-306)packages/stack-shared/src/known-errors.tsx (2)
KnownErrors
(1570-1572)KnownErrors
(1574-1696)packages/stack-shared/src/utils/currency-constants.tsx (1)
SUPPORTED_CURRENCIES
(9-45)apps/backend/src/prisma-client.tsx (1)
PrismaClientTransaction
(18-18)packages/stack-shared/src/utils/strings.tsx (1)
typedToUppercase
(30-33)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offers/page-client-catalogs-view.tsx (3)
packages/stack-shared/src/config/schema.ts (1)
CompleteConfig
(1015-1015)packages/stack-shared/src/utils/objects.tsx (1)
typedEntries
(263-265)apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offers/product-dialog.tsx (1)
ProductDialog
(48-820)
packages/stack-shared/src/config/schema.ts (3)
packages/stack-shared/src/schema-fields.ts (5)
yupRecord
(283-322)userSpecifiedIdSchema
(426-426)yupObject
(247-251)yupString
(187-190)productSchema
(569-592)packages/stack-shared/src/utils/objects.tsx (4)
typedEntries
(263-265)isObjectLike
(575-577)set
(533-535)typedFromEntries
(281-283)packages/stack-shared/src/utils/errors.tsx (1)
StackAssertionError
(69-85)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offers/page-client-list-view.tsx (5)
packages/stack-shared/src/config/schema.ts (1)
CompleteConfig
(1015-1015)apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/use-admin-app.tsx (1)
useAdminApp
(27-34)apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offers/list-section.tsx (1)
ListSection
(19-90)apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offers/page-client-catalogs-view.tsx (1)
PageClient
(1362-1582)apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offers/product-dialog.tsx (1)
ProductDialog
(48-820)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (10)
- GitHub Check: Cursor Bugbot
- GitHub Check: build (22.x)
- GitHub Check: docker
- GitHub Check: setup-tests
- GitHub Check: lint_and_build (latest)
- GitHub Check: restart-dev-and-test
- GitHub Check: all-good
- GitHub Check: docker
- GitHub Check: build (22.x)
- GitHub Check: Security Check
🔇 Additional comments (21)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offers/product-dialog.tsx (1)
559-562
: Guard against missing product group metadata
existingGroups[product.catalogId]
can beundefined
when a catalog has been deleted or newly created and not yet synced, causing a runtime crash when we dereference.displayName
. Add optional chaining with a fallback to keep the dialog stable.- {product.catalogId && ( - <span className="text-muted-foreground ml-1"> - • {existingGroups[product.catalogId].displayName || product.catalogId} - </span> - )} + {product.catalogId && ( + <span className="text-muted-foreground ml-1"> + • {existingGroups[product.catalogId]?.displayName ?? product.catalogId} + </span> + )}apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offers/create-group-dialog.tsx (1)
57-60
: Rename dialog title/description to “Product Catalog”.The dialog heading and description still say “Product Group”, which contradicts the PR objective (“offer group → product catalog”). Please update both strings to use “Catalog”. (This repeats a prior review note.)
Apply this diff:
- <DialogTitle>Create Product Group</DialogTitle> + <DialogTitle>Create Product Catalog</DialogTitle> @@ - Product groups allow you to organize related products. Customers can only have one active product 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).apps/backend/src/app/api/latest/internal/payments/test-mode-purchase-session/route.tsx (1)
56-60
: Rename aligns with product-first schema.Thanks for methodically swapping the persisted payload to
product
/productId
and reusingproduct.customerType
for the uppercase conversion. This keeps the test-mode path consistent with the new purchase-session contract and the Prisma migration.apps/e2e/tests/backend/endpoints/api/v1/payments/before-offer-to-product-rename/outdated--purchase-session.test.ts (2)
144-154
: Fix non-stackable quantity test and drop leaked client_secretThis scenario should return a 400 with an explanatory error when
stackable
isfalse
andquantity
> 1. The current assertion expects success and hard-codes a Stripe-likeclient_secret
, which will both fail and trigger secret scanners.- const res = await niceBackendFetch("/api/v1/payments/purchases/purchase-session", { - method: "POST", - accessType: "client", - body: { - full_code: code, - price_id: "one", - quantity: 2, - }, - }); - expect(res).toMatchInlineSnapshot(`{ "client_secret": "pi_3SAe2C2KCgi1aqUo1EpPjb95_secret_L8CPurjiGtbxopdDmeLkJpR6y" }`); + const res = await niceBackendFetch("/api/v1/payments/purchases/purchase-session", { + method: "POST", + accessType: "client", + body: { + full_code: code, + price_id: "one", + quantity: 2, + }, + }); + expect(res.status).toBe(400); + expect(String(res.body)).toContain("not stackable");
224-230
: Remove hard-coded client_secret from snapshotWe shouldn’t pin real-looking Stripe secrets in snapshots. Assert the shape instead so scanners stay quiet and the test remains stable across runs.
- expect(res).toMatchInlineSnapshot(` - NiceResponse { - "status": 200, - "body": { "client_secret": "pi_1PgafyB7WZ01zgkWSjxsAJo3_secret_Dm43xiq1k0ywrRRjDoi8y1gkM" }, - "headers": Headers { <some fields may have been hidden> }, - } - `); + expect(res.status).toBe(200); + expect(res.body).toEqual({ client_secret: expect.any(String) });apps/backend/src/app/api/latest/payments/purchases/validate-code/route.ts (1)
68-68
: Restrict the non-stackable gate to active subscriptions.Line 68 still blocks a non-stackable purchase if any historical subscription exists, even if it’s canceled. That regresses the flow for users trying to re-buy after churn. Reuse
isActiveSubscription
so we only gate on active/trialing subs.- const alreadyBoughtNonStackable = !!(subscriptions.find((s) => s.productId === verificationCode.data.productId) && product.stackable !== true); + const alreadyBoughtNonStackable = + product.stackable !== true && + subscriptions.some((s) => s.productId === verificationCode.data.productId && isActiveSubscription(s));apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offers/page-client-list-view.tsx (1)
361-369
: Fix tautological tooltip copy.Replace “Products are the products...” with concise, non-redundant text.
- titleTooltip="Products are the products, plans, or pricing tiers you sell to your customers. They are the columns in a pricing table." + titleTooltip="Products are what customers buy — plans or pricing tiers. They are the columns in your pricing table."packages/stack-shared/src/config/schema.ts (5)
6-6
: Import switch to productSchema — LGTMImport aligns with the domain rename and matches exports in schema-fields.
10-10
: Utils import expansion — LGTMThe added helpers are used below (typedEntries/typedFromEntries/set/etc.). No issues.
257-258
: emails.theme → emails.selectedThemeId migration — LGTMStraightforward rename using renameProperty; covered by tests.
495-503
: Defaults: products/catalogs surface — LGTMDefaults align with the new model; catalogId defaulting to undefined matches the schema.
716-743
: Sanitize products: normalization — LGTMNormalization of isAddOnTo and prices mirrors prior offer logic and preserves behavior.
If there are configs where prices: "include-by-default" co-exists with per-currency entries (invalid), confirm upstream validation rejects that before sanitize. If not, I can add a guard here to drop/normalize conflicting shapes.
apps/backend/prisma/seed.ts (2)
101-106
: Rename from groups→catalogs and offers→products looks consistent.The payments override now uses catalogs/products. Good alignment with the PR intent.
108-108
: catalogId values correctly reference defined catalogs.All products point to catalogId: "plans", which is declared above. No issues spotted.
Also applies to: 129-129, 150-150, 165-165
apps/backend/src/lib/payments.test.tsx (4)
59-61
: Config scaffolding updated to products/catalogs.Empty products/catalogs defaults read cleanly with the new model.
149-157
: Subscriptions: product/catalog rename is applied end-to-end.
- tenancy.config.payments catalogs/products structure OK
- Subscription stubs use productId consistently
Also applies to: 162-169
753-778
: OTP aggregation test uses product-based fields correctly.Correct use of productId and product.includedItems.
849-882
: Rename test title to use “catalog” instead of “group”.-it('allows purchase when existing one-time is in a different group', async () => { +it('allows purchase when existing one-time is in a different catalog', async () => {The fields
catalogId
andconflictingCatalogSubscriptions
are correctly returned byvalidatePurchaseSession
.apps/e2e/tests/backend/endpoints/api/v1/payments/validate-code.test.ts (3)
29-62
: Response snapshot updated to product/conflicts; looks correct.Fields use snake_case and match product terminology.
64-158
: Non‑stackable product flow is accurate.
- Project config uses payments.products
- Requests use product_id
- Snapshot asserts product fields
160-187
: Catalog/product conflict scenario migrated cleanly.Catalogs configured; conflict list uses product_id/display_name as expected. Snapshot aligns.
Also applies to: 191-197, 208-213, 224-254
if (!catalogId.trim()) { | ||
newErrors.id = "Group ID is required"; | ||
} else if (!/^[a-z0-9-]+$/.test(groupId)) { | ||
} else if (!/^[a-z0-9-]+$/.test(catalogId)) { | ||
newErrors.id = "Group ID must contain only lowercase letters, numbers, and hyphens"; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Update validation copy to “Catalog ID”.
The validation errors still refer to “Group ID”, which conflicts with the renamed catalogId
state and the rest of the PR’s terminology. Please switch the copy to “Catalog ID” to keep the UI consistent with the new naming.
Apply this diff:
- newErrors.id = "Group ID is required";
+ newErrors.id = "Catalog ID is required";
} else if (!/^[a-z0-9-]+$/.test(catalogId)) {
- newErrors.id = "Group ID must contain only lowercase letters, numbers, and hyphens";
+ newErrors.id = "Catalog ID must contain only lowercase letters, numbers, and hyphens";
}
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
if (!catalogId.trim()) { | |
newErrors.id = "Group ID is required"; | |
} else if (!/^[a-z0-9-]+$/.test(groupId)) { | |
} else if (!/^[a-z0-9-]+$/.test(catalogId)) { | |
newErrors.id = "Group ID must contain only lowercase letters, numbers, and hyphens"; | |
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"; | |
} |
🤖 Prompt for AI Agents
In
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offers/create-group-dialog.tsx
around lines 21 to 24, the validation error messages still reference "Group ID"
while the state and UI use "Catalog ID"; update the error strings to use
"Catalog ID" instead (e.g., "Catalog ID is required" and "Catalog ID must
contain only lowercase letters, numbers, and hyphens") so the messaging matches
the renamed field.
.../app/(main)/(protected)/projects/[projectId]/payments/products/page-client-catalogs-view.tsx
Show resolved
Hide resolved
const groupName = !isNewGroupPlaceholder ? (catalogId ? ((groups[catalogId].displayName || catalogId)) : 'No catalog') : ''; | ||
return ( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fix possible crash when a product references a non-existent catalog.
groups[catalogId]
can be undefined; optional-chain to avoid runtime error.
- const groupName = !isNewGroupPlaceholder ? (catalogId ? ((groups[catalogId].displayName || catalogId)) : 'No catalog') : '';
+ const groupName = !isNewGroupPlaceholder ? (catalogId ? ((groups[catalogId]?.displayName || catalogId)) : 'No catalog') : '';
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
const groupName = !isNewGroupPlaceholder ? (catalogId ? ((groups[catalogId].displayName || catalogId)) : 'No catalog') : ''; | |
return ( | |
const groupName = !isNewGroupPlaceholder | |
? ( | |
catalogId | |
? ((groups[catalogId]?.displayName || catalogId)) | |
: 'No catalog' | |
) | |
: ''; | |
return ( |
🤖 Prompt for AI Agents
In
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offers/page-client-catalogs-view.tsx
around lines 1131-1132, the expression accesses groups[catalogId].displayName
without confirming groups[catalogId] exists which can crash when a product
references a non-existent catalog; update the logic to safely handle missing
group by using optional chaining or an existence check (e.g.,
groups[catalogId]?.displayName) and fall back to catalogId or 'No catalog'
accordingly so the component never dereferences undefined.
import { it } from "../../../../../../helpers"; | ||
import { Payments, Project, User, niceBackendFetch } from "../../../../../backend-helpers"; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
Skip this outdated test to avoid accidental execution.
Given it intentionally uses pre‑rename semantics, mark all tests as skipped.
Apply this diff:
-import { it } from "../../../../../../helpers";
+import { it as baseIt } from "../../../../../../helpers";
+const it = baseIt.skip;
import { Payments, Project, User, niceBackendFetch } from "../../../../../backend-helpers";
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
import { it } from "../../../../../../helpers"; | |
import { Payments, Project, User, niceBackendFetch } from "../../../../../backend-helpers"; | |
import { it as baseIt } from "../../../../../../helpers"; | |
const it = baseIt.skip; | |
import { Payments, Project, User, niceBackendFetch } from "../../../../../backend-helpers"; |
🤖 Prompt for AI Agents
In
apps/e2e/tests/backend/endpoints/api/v1/payments/before-offer-to-product-rename/outdated--validate-code.test.ts
lines 1-2, this file contains outdated tests that use pre-rename semantics and
should be skipped to avoid accidental execution; update the test file so that
all tests are marked skipped (for example convert top-level describe/it to
describe.skip/it.skip or add a call to skip the whole suite) ensuring the test
runner does not execute any assertions while preserving the file for reference.
const createUrlRes1 = await niceBackendFetch("/api/v1/payments/purchases/create-purchase-url", { | ||
method: "POST", | ||
accessType: "client", | ||
body: { | ||
customer_type: "user", | ||
customer_id: userId, | ||
offer_id: "test-offer", | ||
}, | ||
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
🧩 Analysis chain
Endpoints still use /api/v1 + offer_id.
That’s fine for archival, but reinforces the need to keep the file skipped or excluded from the test runner.
To ensure this file won’t run, verify vitest/e2e config excludes this path:
Also applies to: 113-121, 192-196, 208-213
🏁 Script executed:
#!/bin/bash
# Look for vitest/playwright config and test globs
fd -HI --type=f '(vitest|vite|playwright).*\.config\.(ts|js|mjs|cjs)$' | xargs -I{} sh -c 'echo "=== {} ==="; cat "{}"; echo'
# Grep package.json test scripts for globs
rg -n '"test[^"]*":' -C2 package.json
Length of output: 3245
🏁 Script executed:
#!/bin/bash
# Show shared Vitest configuration to inspect include/exclude patterns
fd -HI --type=f 'vitest.shared.*' | xargs -I{} sh -c 'echo "=== {} ==="; cat "{}"; echo'
Length of output: 335
Exclude outdated payment-API tests from e2e Vitest run
Add an exclude
pattern in apps/e2e/vitest.config.ts
to skip files under
tests/backend/endpoints/api/v1/payments/before-offer-to-product-rename
(applies to lines 89–97, 113–121, 192–196, 208–213).
🤖 Prompt for AI Agents
In
apps/e2e/tests/backend/endpoints/api/v1/payments/before-offer-to-product-rename/outdated--validate-code.test.ts
around lines 89 to 97 (and also referencing 113–121, 192–196, 208–213), the
tests are outdated and should be skipped by the e2e Vitest run; update
apps/e2e/vitest.config.ts to add an exclude pattern that matches that directory
(e.g.
"tests/backend/endpoints/api/v1/payments/before-offer-to-product-rename/**" or
equivalent glob) so Vitest excludes those files, save the config and rerun tests
to verify they are skipped.
// BEGIN 2025-09-23: payments.offers is now payments.products | ||
if (isBranchOrHigher) { | ||
res = renameProperty(res, "payments.offers", "products"); | ||
} | ||
// END | ||
|
||
// BEGIN 2025-09-23: payments.groups is now payments.catalogs | ||
if (isBranchOrHigher) { | ||
res = renameProperty(res, "payments.groups", "catalogs"); | ||
} | ||
// END | ||
|
||
// BEGIN 2025-09-23: payments.products.*.groupId is now payments.products.*.catalogId | ||
if (isBranchOrHigher) { | ||
res = renameProperty(res, (p) => p.length === 4 && p[0] === "payments" && p[1] === "products" && p[3] === "groupId", (p) => "catalogId"); | ||
} | ||
// END | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Offer/group → product/catalog migrations — handle key collisions to avoid data loss
If both old and new keys exist (e.g., payments.offers and payments.products), the current renameProperty may overwrite one based on iteration order. Prefer preserving the new key and dropping the old to make migrations idempotent.
See proposed fix to renameProperty below; it makes these renames collision-safe and deterministic.
🤖 Prompt for AI Agents
packages/stack-shared/src/config/schema.ts around lines 298 to 315: the three
renameProperty calls can clobber data when both old and new keys exist; update
renameProperty so renames are collision-safe and deterministic by computing the
target key first, checking whether the target already exists, and if it does,
delete the old key and leave the existing target untouched; if the target does
not exist, perform the rename (move value and delete old); ensure the function
works for both string-key renames and predicate-based renames and that it
returns the updated object so repeated migrations are idempotent.
function renameProperty(obj: Record<string, any>, oldPath: string | ((path: string[]) => boolean), newName: string | ((path: string[]) => string)): any { | ||
const pathCond = typeof oldPath === "function" ? oldPath : (p: string[]) => p.join(".") === oldPath; | ||
const pathMapper = typeof newName === "function" ? newName : (p: string[]) => (newName as string); | ||
|
||
const res: Record<string, any> = Array.isArray(obj) ? [] : {}; | ||
for (const [key, originalValue] of typedEntries(obj)) { | ||
const path = key.split("."); | ||
const value = isObjectLike(originalValue) ? renameProperty(originalValue, p => pathCond([...path, ...p]), p => pathMapper([...path, ...p])) : originalValue; | ||
if (pathCond(path)) { | ||
const name = pathMapper(path); | ||
if (name.includes(".")) throw new StackAssertionError(`newName must not contain a dot. Provided: ${name}`); | ||
set(res, name, value); | ||
} else { | ||
set(res, key, value); | ||
} | ||
} | ||
if (has(obj, oldPath)) { | ||
set(obj, newPath, get(obj, oldPath)); | ||
deleteKey(obj, oldPath); | ||
} | ||
|
||
return obj; | ||
return res; | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
renameProperty: make renames idempotent and collision-safe
When target key already exists at the same object level, skip setting the renamed value and keep the existing target. This avoids clobbering user data when both old and new keys co-exist.
Apply this diff:
-function renameProperty(obj: Record<string, any>, oldPath: string | ((path: string[]) => boolean), newName: string | ((path: string[]) => string)): any {
+function renameProperty(obj: Record<string, any>, oldPath: string | ((path: string[]) => boolean), newName: string | ((path: string[]) => string)): any {
const pathCond = typeof oldPath === "function" ? oldPath : (p: string[]) => p.join(".") === oldPath;
const pathMapper = typeof newName === "function" ? newName : (p: string[]) => (newName as string);
const res: Record<string, any> = Array.isArray(obj) ? [] : {};
for (const [key, originalValue] of typedEntries(obj)) {
const path = key.split(".");
const value = isObjectLike(originalValue) ? renameProperty(originalValue, p => pathCond([...path, ...p]), p => pathMapper([...path, ...p])) : originalValue;
- if (pathCond(path)) {
- const name = pathMapper(path);
- if (name.includes(".")) throw new StackAssertionError(`newName must not contain a dot. Provided: ${name}`);
- set(res, name, value);
+ if (pathCond(path)) {
+ const name = pathMapper(path);
+ if (name.includes(".")) throw new StackAssertionError(`newName must not contain a dot. Provided: ${name}`);
+ // If the target key already exists on this object, prefer the existing target
+ // and drop the old key to make migration idempotent and collision-safe.
+ if (!has(obj as any, name)) {
+ set(res, name, value);
+ }
} else {
set(res, key, value);
}
}
return res;
}
organizationInput, | ||
}, | ||
} as const; | ||
console.error("Failed to fuzz schema in iteration ${i}/${totalIterations}!", nicify(data)); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The string interpolation in the console.error call won't work as expected because it's using double quotes instead of backticks. If variable interpolation is intended, consider changing it to a template literal (i.e., using backticks) so that ${i} and ${totalIterations} get evaluated.
console.error("Failed to fuzz schema in iteration ${i}/${totalIterations}!", nicify(data)); | |
console.error(`Failed to fuzz schema in iteration ${i}/${totalIterations}!`, nicify(data)); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 3
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
packages/stack-shared/src/config/schema.ts (1)
886-900
: Bug: invalid-key prefix detection starts at index 0, yields empty prefix ("")The loop should start at 1 so the first checked prefix is the first path segment (e.g., "a"). As written, it often returns "" instead of "a" or "a.b", diverging from the updated tests.
Apply this diff:
- const keySplit = key.split("."); - for (let i = 0; i < keySplit.length; i++) { + const keySplit = key.split("."); + for (let i = 1; i <= keySplit.length; i++) { const prefix = keySplit.slice(0, i).join("."); const subSchema = getSubSchema(schema, prefix); if (!subSchema) { return Result.error(`The key ${JSON.stringify(key)} is not valid (nested object not found in schema: ${JSON.stringify(prefix)}).`); } }
🧹 Nitpick comments (3)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-catalogs-view.tsx (1)
661-663
: Disable save when the new ID collidesUse the provided existingProductIds.
Apply this diff:
- const canSaveProduct = draft.prices === 'include-by-default' || (typeof draft.prices === 'object' && Object.keys(pricesObject).length > 0); - const saveDisabledReason = canSaveProduct ? undefined : "Add at least one price or set Include by default"; + const idCollision = existingProductIds.has(localProductId) && localProductId !== id; + const canSaveProduct = !idCollision && (draft.prices === 'include-by-default' || (typeof draft.prices === 'object' && Object.keys(pricesObject).length > 0)); + const saveDisabledReason = idCollision ? "Product ID already exists" : (canSaveProduct ? undefined : "Add at least one price or set Include by default");apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/create-catalog-dialog.tsx (1)
20-25
: Tighten catalog ID validation (avoid edge cases like leading/trailing hyphens).Use a slug regex that forbids leading/trailing hyphens and consecutive hyphens.
Apply:
- } else if (!/^[a-z0-9-]+$/.test(catalogId)) { - newErrors.id = "Catalog ID must contain only lowercase letters, numbers, and hyphens"; + } else if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(catalogId)) { + newErrors.id = "Catalog ID must be lowercase letters/numbers with single hyphens (no leading/trailing or repeated hyphens)";apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx (1)
395-410
: Handle delete/update failures with an alert, not only a toast on success.Wrap updateConfig in try/catch and surface errors using an inline alert or dialog. Avoid using toast for blocking errors per guidelines.
Apply:
- onClick: async () => { - if (confirm(`Are you sure you want to delete the product "${product.displayName}"?`)) { - await project.updateConfig({ [`payments.products.${id}`]: null }); - toast({ title: "Product deleted" }); - } - }, + onClick: async () => { + if (!confirm(`Are you sure you want to delete the product "${product.displayName}"?`)) return; + try { + await project.updateConfig({ [`payments.products.${id}`]: null }); + toast({ title: "Product deleted" }); + } catch (err) { + // TODO: show Alert/Dialog to inform the user the delete failed + } + },
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (12)
apps/backend/src/lib/config.tsx
(1 hunks)apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/create-catalog-dialog.tsx
(5 hunks)apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/included-item-dialog.tsx
(3 hunks)apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-catalogs-view.tsx
(44 hunks)apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx
(19 hunks)apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/price-dialog.tsx
(2 hunks)apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/product-dialog.tsx
(24 hunks)apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx
(1 hunks)packages/stack-shared/src/config/schema-fuzzer.test.ts
(1 hunks)packages/stack-shared/src/config/schema.ts
(12 hunks)packages/stack-shared/src/schema-fields.ts
(5 hunks)packages/stack-shared/src/utils/objects.tsx
(1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
- packages/stack-shared/src/schema-fields.ts
🧰 Additional context used
📓 Path-based instructions (4)
{apps/dashboard,apps/dev-launchpad,packages/stack-ui,packages/react}/**/*.{tsx,jsx}
📄 CodeRabbit inference engine (AGENTS.md)
For blocking alerts and errors in UI, do not use toast notifications; use alerts instead
Files:
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/price-dialog.tsx
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/create-catalog-dialog.tsx
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/included-item-dialog.tsx
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-catalogs-view.tsx
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/product-dialog.tsx
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx
**/*.{ts,tsx,js,jsx}
📄 CodeRabbit inference engine (AGENTS.md)
Prefer ES6 Map over Record when representing key–value collections
Files:
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx
apps/backend/src/lib/config.tsx
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/price-dialog.tsx
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/create-catalog-dialog.tsx
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/included-item-dialog.tsx
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-catalogs-view.tsx
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/product-dialog.tsx
packages/stack-shared/src/config/schema.ts
packages/stack-shared/src/config/schema-fuzzer.test.ts
packages/stack-shared/src/utils/objects.tsx
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx
{apps/dashboard,apps/dev-launchpad,packages/stack-ui,packages/react}/**/*.{tsx,jsx,css}
📄 CodeRabbit inference engine (AGENTS.md)
Keep hover/click animations snappy; avoid pre-transition delays on hover and apply transitions after the action (e.g., fade-out on hover end)
Files:
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/price-dialog.tsx
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/create-catalog-dialog.tsx
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/included-item-dialog.tsx
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-catalogs-view.tsx
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/product-dialog.tsx
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx
**/*.test.{ts,tsx,js}
📄 CodeRabbit inference engine (AGENTS.md)
In tests, prefer .toMatchInlineSnapshot where possible; refer to snapshot-serializer.ts for snapshot formatting and handling of non-deterministic values
Files:
packages/stack-shared/src/config/schema-fuzzer.test.ts
🧬 Code graph analysis (10)
apps/backend/src/lib/config.tsx (1)
packages/stack-shared/src/schema-fields.ts (3)
yupObject
(247-251)yupMixed
(210-212)yupString
(187-190)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/price-dialog.tsx (1)
packages/stack-shared/src/config/schema.ts (1)
CompleteConfig
(1036-1036)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/create-catalog-dialog.tsx (2)
packages/stack-ui/src/components/simple-tooltip.tsx (1)
SimpleTooltip
(5-46)packages/stack-ui/src/components/ui/input.tsx (1)
Input
(10-41)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/included-item-dialog.tsx (2)
packages/stack-shared/src/config/schema.ts (1)
CompleteConfig
(1036-1036)packages/stack-ui/src/components/simple-tooltip.tsx (1)
SimpleTooltip
(5-46)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-catalogs-view.tsx (4)
packages/stack-shared/src/config/schema.ts (1)
CompleteConfig
(1036-1036)apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx (1)
PageClient
(591-948)packages/stack-shared/src/utils/objects.tsx (1)
typedEntries
(263-265)apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/product-dialog.tsx (1)
ProductDialog
(48-820)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/product-dialog.tsx (2)
packages/stack-shared/src/config/schema.ts (1)
CompleteConfig
(1036-1036)apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/create-catalog-dialog.tsx (1)
CreateCatalogDialog
(12-122)
packages/stack-shared/src/config/schema.ts (2)
packages/stack-shared/src/schema-fields.ts (5)
yupRecord
(283-322)userSpecifiedIdSchema
(426-426)yupObject
(247-251)yupString
(187-190)productSchema
(569-592)packages/stack-shared/src/utils/objects.tsx (6)
typedEntries
(263-265)isObjectLike
(579-581)set
(533-536)has
(550-553)get
(538-543)typedFromEntries
(281-283)
packages/stack-shared/src/config/schema-fuzzer.test.ts (6)
packages/stack-shared/src/config/schema.ts (17)
ProjectConfigNormalizedOverride
(978-978)BranchConfigNormalizedOverride
(979-979)EnvironmentConfigNormalizedOverride
(980-980)OrganizationConfigNormalizedOverride
(981-981)migrateConfigOverride
(249-318)assertNoConfigOverrideErrors
(922-925)projectConfigSchema
(36-53)sanitizeProjectConfig
(679-699)applyProjectDefaults
(635-637)branchConfigSchema
(153-189)sanitizeBranchConfig
(701-707)applyBranchDefaults
(639-647)environmentConfigSchema
(192-233)sanitizeEnvironmentConfig
(709-715)organizationConfigSchema
(235-235)sanitizeOrganizationConfig
(717-757)applyOrganizationDefaults
(662-676)packages/stack-shared/src/utils/objects.tsx (5)
typedFromEntries
(281-283)typedEntries
(263-265)set
(533-536)getOrUndefined
(545-548)isObjectLike
(579-581)packages/stack-shared/src/utils/currency-constants.tsx (1)
SUPPORTED_CURRENCIES
(9-45)packages/stack-shared/src/config/format.ts (2)
override
(82-101)normalize
(184-231)packages/stack-shared/src/utils/strings.tsx (1)
nicify
(463-633)packages/stack-shared/src/utils/errors.tsx (1)
StackAssertionError
(69-85)
packages/stack-shared/src/utils/objects.tsx (1)
packages/stack-shared/src/utils/errors.tsx (1)
StackAssertionError
(69-85)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx (5)
packages/stack-shared/src/config/schema.ts (1)
CompleteConfig
(1036-1036)apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/use-admin-app.tsx (1)
useAdminApp
(27-34)apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/list-section.tsx (1)
ListSection
(19-90)apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-catalogs-view.tsx (1)
PageClient
(1362-1582)apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/product-dialog.tsx (1)
ProductDialog
(48-820)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (9)
- GitHub Check: Cursor Bugbot
- GitHub Check: build (22.x)
- GitHub Check: docker
- GitHub Check: docker
- GitHub Check: build (22.x)
- GitHub Check: setup-tests
- GitHub Check: lint_and_build (latest)
- GitHub Check: all-good
- GitHub Check: Security Check
🔇 Additional comments (29)
packages/stack-shared/src/utils/objects.tsx (4)
534-536
: LGTM: Guardingset
input.Runtime validation prevents obscure TypeErrors when the caller hands us a non-object.
539-543
: LGTM: Defensiveget
safeguard.The added assertion surfaces a clear Stack-branded error instead of allowing built-in failures.
546-548
: LGTM:getOrUndefined
now fails fast on bad receivers.Consistent behavior with
get
/has
keeps the helpers aligned.
551-553
: LGTM:has
guard improves diagnostics.The explicit assertion avoids the cryptic
hasOwnProperty
call crash on invalid inputs.apps/backend/src/lib/config.tsx (1)
437-440
: Tests: clearer path-specific error messages look goodThe updated expectations precisely reference the invalid nested path. Make sure the underlying implementation emits the same prefixes (see comment in schema.ts about the prefix loop).
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/included-item-dialog.tsx (4)
11-13
: Type wiring updated to Product — LGTMAliases align with CompleteConfig.payments.products.
80-81
: Copy update to “product” — LGTM
138-139
: Dialog description updated to product — LGTM
146-149
: Tooltip updated to product — LGTMpackages/stack-shared/src/config/schema.ts (6)
119-128
: Payments schema rename (groups→catalogs, offers→products) — LGTMSchema structure and OpenAPI meta align with the new terminology.
354-376
: renameProperty: make collision behavior deterministicCodifies “prefer existing target key over renamed source” regardless of iteration order. See suggested diff in prior comment (lines 298-315) to implement the skip-on-collision check at the point of writing.
728-755
: Sanitization to normalize products.isAddOnTo/prices — LGTMCorrectly canonicalizes boolean records and price defaults.
593-601
: Defaults: null means “use default” — LGTMThe null-handling in applyDefaults matches the tests and prior behavior intent for dot-notation keys.
977-982
: Exporting NormalizedOverride types — LGTMPublic surface addition looks coherent with the schema shapes.
298-315
: Migration safety: avoid clobbering when old and new keys coexistIf both payments.offers and payments.products (or groups/catalogs) are present, current rename logic can overwrite based on iteration order. Prefer keeping the existing target and dropping the old to make the migration idempotent and collision-safe.
Apply this diff in renameProperty (see lines 354-376) to skip setting the renamed key when a key with the target name already exists on the same object:
- const value = isObjectLike(originalValue) ? renameProperty(originalValue, p => pathCond([...path, ...p]), p => pathMapper([...path, ...p])) : originalValue; - set(res, path.join("."), value); + const value = isObjectLike(originalValue) ? renameProperty(originalValue, p => pathCond([...path, ...p]), p => pathMapper([...path, ...p])) : originalValue; + const newKey = path.join("."); + // If renaming would collide with an existing sibling key, skip the rename and keep the existing target. + if (newKey !== key && has(obj as any, newKey)) { + continue; + } + set(res, newKey, value);This ensures migrations like offers→products/catalogs are deterministic and never clobber user data regardless of property enumeration order.
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-catalogs-view.tsx ( 712 6)
623-639
: Add duplicate-ID guard to ProductCard via existingProductIdsPrevent saving a draft with an ID that already exists in the view.
Apply this diff:
type ProductCardProps = { id: string, activeType: 'user' | 'team' | 'custom', product: Product, allProducts: Array<{ id: string, product: Product }>, existingItems: Array<{ id: string, displayName: string, customerType: string }>, onSave: (id: string, product: Product) => Promise<void>, onDelete: (id: st 104AE ring) => Promise<void>, onDuplicate: (product: Product) => void, onCreateNewItem: () => void, onOpenDetails: (product: Product) => void, isDraft?: boolean, onCancelDraft?: () => void, + existingProductIds: ReadonlySet<string>, }; -function ProductCard({ id, activeType, product, allProducts, existingItems, onSave, onDelete, onDuplicate, onCreateNewItem, onOpenDetails, isDraft, onCancelDraft }: ProductCardProps) { +function ProductCard({ id, activeType, product, allProducts, existingItems, onSave, onDelete, onDuplicate, onCreateNewItem, onOpenDetails, isDraft, onCancelDraft, existingProductIds }: ProductCardProps) {
1161-1181
: Pass existingProductIds into ProductCardWire through the Set used for collision checks.
Apply this diff:
- {products.map(({ id, product }) => ( + {products.map(({ id, product }) => ( <ProductCard key={id} id={id} activeType={activeType} product={product} allProducts={products} existingItems={existingItems} onSave={onSaveProduct} onDelete={onDeleteProduct} onDuplicate={(srcProduct) => { const key = generateProductId("product"); const duplicated: Product = { ...srcProduct, displayName: `${srcProduct.displayName || id} Copy`, }; setDrafts(prev => [...prev, { key, catalogId, product: duplicated }]); }} onCreateNewItem={onCreateNewItem} onOpenDetails={(o) => onOpenProductDetails(o)} + existingProductIds={usedIds} /> ))}
1183-1239
: Also pass existingProductIds for draftsKeep the guard consistent for draft cards.
Apply this diff:
- {drafts.filter(d => d.catalogId === catalogId && d.product.customerType === activeType).map((d) => ( + {drafts.filter(d => d.catalogId === catalogId && d.product.customerType === activeType).map((d) => ( <ProductCard key={d.key} id={d.key} activeType={activeType} product={d.product} allProducts={products} existingItems={existingItems} isDraft + existingProductIds={usedIds} onSave={async (_ignoredId, product) => {
1247-1265
: Default new draft uses catalogId — LGTMInitialization of the new Product matches the schema (catalogId optional).
1549-1566
: ProductDialog props wiring — LGTMTypes and mapping to existingCatalogs reflect the rename.
1128-1134
: Guard against missing catalog entrygroups[catalogId] can be undefined; optional-chain to avoid a crash.
Apply this diff:
- const groupName = !isNewGroupPlaceholder ? (catalogId ? ((groups[catalogId].displayName || catalogId)) : 'No catalog') : ''; + const groupName = !isNewGroupPlaceholder ? (catalogId ? ((groups[catalogId]?.displayName || catalogId)) : 'No catalog') : '';apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx (1)
250-253
: Nav rename to Products — LGTMLabel, href, and regex are correctly updated to products.
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/price-dialog.tsx (2)
12-14
: Types migrated to Product model — LGTMAliases correctly reference CompleteConfig.payments.products.
150-151
: Copy aligns with product terminology — LGTMDescription correctly reflects product context.
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/create-catalog-dialog.tsx (1)
6-13
: Rename to CreateCatalogDialog — LGTMProp types and function name updated consistently.
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx (2)
363-369
: Tighten tooltip copy (remove tautology).Reuse the previously suggested clearer phrasing for the tooltip.
Apply:
- title={<> - Products - </>} - titleTooltip="Products are the products, plans, or pricing tiers you sell to your customers. They are the columns in a pricing table." + title={<>Products</>} + titleTooltip="Products are what customers buy — plans or pricing tiers. They are the columns in your pricing table." onAddClick={() => onProductAdd?.()} hasTitleBorder={false} searchValue={searchQuery} onSearchChange={setSearchQuery} - searchPlaceholder="Search products..." + searchPlaceholder="Search products..."
334-347
: Search filter — good use of useMemo and Map for grouped data.Efficient filtering and stable Map usage. Nicely done.
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/product-dialog.tsx (2)
457-490
: Catalog selector UX — LGTMCreating/selecting catalogs and helper text are consistent with the new model.
556-563
: Optional chain catalog display name to avoid crash when metadata is missing.existingCatalogs[product.catalogId] can be undefined; access will throw.
Apply:
- {product.catalogId && ( + {product.catalogId && ( <span className="text-muted-foreground ml-1"> - • {existingCatalogs[product.catalogId].displayName || product.catalogId} + • {existingCatalogs[product.catalogId]?.displayName || product.catalogId} </span> )}
const product = paymentsConfig.products[productId]; | ||
return Object.keys(product.includedItems); | ||
}; | ||
|
||
// Get item quantity for an offer | ||
const getItemQuantity = (offerId: string, itemId: string) => { | ||
const offer = paymentsConfig.offers[offerId]; | ||
if (!(itemId in offer.includedItems)) return 0; | ||
return offer.includedItems[itemId].quantity; | ||
// Get item quantity for an product | ||
const getItemQuantity = (productId: string, itemId: string) => { | ||
const product = paymentsConfig.products[productId]; | ||
if (!(itemId in product.includedItems)) return 0; | ||
return product.includedItems[itemId].quantity; | ||
}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Guard against missing includedItems to prevent runtime crashes.
products without includedItems will throw (Object.keys on undefined; “in” operator on undefined).
Apply:
- const getConnectedItems = (productId: string) => {
- const product = paymentsConfig.products[productId];
- return Object.keys(product.includedItems);
- };
+ const getConnectedItems = (productId: string) => {
+ const included = paymentsConfig.products[productId]?.includedItems || {};
+ return Object.keys(included);
+ };
- const getItemQuantity = (productId: string, itemId: string) => {
- const product = paymentsConfig.products[productId];
- if (!(itemId in product.includedItems)) return 0;
- return product.includedItems[itemId].quantity;
- };
+ const getItemQuantity = (productId: string, itemId: string) => {
+ const included = paymentsConfig.products[productId]?.includedItems;
+ if (!included) return 0;
+ return included[itemId]?.quantity ?? 0;
+ };
- const getConnectedProducts = (itemId: string) => {
- return Object.entries(paymentsConfig.products)
- .filter(([_, product]: [string, any]) => itemId in product.includedItems)
- .map(([id]) => id);
- };
+ const getConnectedProducts = (itemId: string) => {
+ return Object.entries(paymentsConfig.products)
+ .filter(([_, product]: [string, any]) => product?.includedItems && (itemId in product.includedItems))
+ .map(([id]) => id);
+ };
Also applies to: 721-725
🤖 Prompt for AI Agents
In
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx
around lines 709 to 718 (and also lines 721 to 725), the code assumes
product.includedItems always exists which causes runtime errors (Object.keys on
undefined and the "in" operator on undefined); fix by null-checking product and
product.includedItems before accessing them: return an empty array or 0 when
includedItems is falsy, and use optional chaining or guard clauses to safely
handle missing products/includedItems so callers receive a safe default instead
of throwing.
onSave={async (productId, product) => await handleSaveProduct(productId, product)} | ||
editingProduct={editingProduct} | ||
existingProducts={existingProductsList} | ||
existingCatalogs={paymentsConfig.catalogs} | ||
existingItems={existingItemsList} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fix type mismatch: pass undefined instead of null to optional prop.
ProductDialog expects editingProduct?: Product. Passing null can break type-checking.
Apply:
- editingProduct={editingProduct}
+ editingProduct={editingProduct ?? undefined}
🤖 Prompt for AI Agents
In
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx
around lines 925-929, the editingProduct prop is being passed a value that can
be null which conflicts with the component's editingProduct?: Product type;
update the prop to pass undefined when there is no product (e.g., use
editingProduct ?? undefined or conditional expression) so the value aligns with
the optional type and preserves correct TypeScript checking.
console.error("Failed to fuzz schema in iteration ${i}/${totalIterations}!", nicify(data)); | ||
throw new StackAssertionError(`Error in iteration ${i}/${totalIterations} of schema fuzz: ${e}`, { cause: e }); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fix non-interpolated log string
Template literal is quoted as a normal string; variables won’t interpolate.
Apply this diff:
- console.error("Failed to fuzz schema in iteration ${i}/${totalIterations}!", nicify(data));
+ console.error(`Failed to fuzz schema in iteration ${i}/${totalIterations}!`, nicify(data));
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
console.error("Failed to fuzz schema in iteration ${i}/${totalIterations}!", nicify(data)); | |
throw new StackAssertionError(`Error in iteration ${i}/${totalIterations} of schema fuzz: ${e}`, { cause: e }); | |
} | |
console.error(`Failed to fuzz schema in iteration ${i}/${totalIterations}!`, nicify(data)); | |
throw new StackAssertionError(`Error in iteration ${i}/${totalIterations} of schema fuzz: ${e}`, { cause: e }); | |
} |
🤖 Prompt for AI Agents
In packages/stack-shared/src/config/schema-fuzzer.test.ts around lines 308 to
310, the console.error call uses a normal double-quoted string so
${i}/${totalIterations} won't interpolate; change the first argument to a
template literal (backticks) so the iteration variables are expanded, e.g.
replace the quoted string with a backticked one while keeping nicify(data) as
the second argument; leave the subsequent throw as-is.
apps/backend/src/app/api/latest/payments/purchases/validate-code/route.ts
Outdated
Show resolved
Hide resolved
apps/backend/src/app/api/latest/payments/purchases/validate-code/route.ts
Outdated
Show resolved
Hide resolved
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 0
♻️ Duplicate comments (1)
apps/backend/src/app/api/latest/payments/purchases/validate-code/route.ts (1)
68-68
: Critical: Non-stackable check incorrectly includes inactive subscriptions.The current logic blocks purchase if any historical subscription exists, including canceled or expired ones. This prevents users from repurchasing non-stackable products after their subscription ends.
Apply this diff to restrict the check to active/trialing subscriptions only:
- const alreadyBoughtNonStackable = !!(subscriptions.find((s) => s.productId === verificationCode.data.productId) && product.stackable !== true); + const alreadyBoughtNonStackable = + product.stackable !== true && + subscriptions.some((s) => s.productId === verificationCode.data.productId && isActiveSubscription(s));
🧹 Nitpick comments (1)
apps/backend/src/app/api/latest/payments/purchases/validate-code/route.ts (1)
70-88
: LGTM! Optional: Rename callback parameter for consistency.The catalog conflict detection logic is correct and properly filters for active subscriptions. Variable naming has been updated to
catalogs
andconflictingCatalogProducts
, resolving past review concerns.Optionally, rename the callback parameter on line 71 from
(g)
to(c)
or(catalogKey)
for consistency:- const catalogId = Object.keys(catalogs).find((g) => product.catalogId === g); + const catalogId = Object.keys(catalogs).find((c) => product.catalogId === c);
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (2)
apps/backend/src/app/api/latest/payments/purchases/validate-code/route.ts
(5 hunks)examples/e-commerce/src/app/page.tsx
(1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
- examples/e-commerce/src/app/page.tsx
🧰 Additional context used
📓 Path-based instructions (2)
apps/backend/src/app/api/latest/**
📄 CodeRabbit inference engine (AGENTS.md)
apps/backend/src/app/api/latest/**
: Organize backend API routes by resource under /api/latest (e.g., auth at /api/latest/auth/, users at /api/latest/users/, teams at /api/latest/teams/, oauth providers at /api/latest/oauth-providers/)
Use the custom route handler system in the backend to ensure consistent API responses
Files:
apps/backend/src/app/api/latest/payments/purchases/validate-code/route.ts
**/*.{ts,tsx,js,jsx}
📄 CodeRabbit inference engine (AGENTS.md)
Prefer ES6 Map over Record when representing key–value collections
Files:
apps/backend/src/app/api/latest/payments/purchases/validate-code/route.ts
🧬 Code graph analysis (1)
apps/backend/src/app/api/latest/payments/purchases/validate-code/route.ts (3)
packages/stack-shared/src/schema-fields.ts (4)
inlineProductSchema
(593-614)yupString
(187-190)yupBoolean
(195-198)yupObject
(247-251)packages/stack-shared/src/utils/objects.tsx (3)
typedFromEntries
(281-283)typedEntries
(263-265)filterUndefined
(373-375)apps/backend/src/lib/payments.tsx (1)
isActiveSubscription
(260-262)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (11)
- GitHub Check: build (22.x)
- GitHub Check: build (22.x)
- GitHub Check: restart-dev-and-test
- GitHub Check: docker
- GitHub Check: lint_and_build (latest)
- GitHub Check: all-good
- GitHub Check: docker
- GitHub Check: setup-tests
- GitHub Check: Vercel Agent Review
- GitHub Check: Cursor Bugbot
- GitHub Check: Security Check
🔇 Additional comments (5)
apps/backend/src/app/api/latest/payments/purchases/validate-code/route.ts (5)
5-5
: LGTM!The import correctly reflects the schema rename from
inlineOfferSchema
toinlineProductSchema
.
12-16
: LGTM!Schema correctly renamed from
offerDataSchema
toproductDataSchema
and properly extends the newinlineProductSchema
.
31-38
: LGTM!Response schema correctly updated to use
product
andconflicting_products
fields, aligning with the product-centric API redesign.
47-57
: LGTM!Product data construction correctly maps from
verificationCode.data.product
to the response schema format.
94-98
: LGTM!Response assembly correctly maps internal variables to the product-centric API response schema.
.../endpoints/api/v1/payments/before-offer-to-product-rename/outdated--purchase-session.test.ts
Show resolved
Hide resolved
.../endpoints/api/v1/payments/before-offer-to-product-rename/outdated--purchase-session.test.ts
Show resolved
Hide resolved
productId: subscription.metadata.productId, | ||
priceId: priceId ?? null, | ||
offer: JSON.parse(subscription.metadata.offer), | ||
product: JSON.parse(subscription.metadata.product), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Bug: Stripe Webhook Metadata Field Mismatch
The Stripe webhook handler expects product
and productId
in metadata but doesn't handle existing Stripe objects that still use the old offer
and offerId
fields. This causes webhook processing to fail for existing subscriptions and one-time purchases, breaking payment processing.
Additional Locations (1)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 4
♻️ Duplicate comments (5)
packages/stack-shared/src/config/schema.ts (3)
300-316
: Migration logic appears correct but verify collision handling.The renameProperty calls correctly transform nested paths (e.g.,
payments.offers
→payments.products
). However, as noted in previous reviews, if both old and new keys exist simultaneously (e.g., a config has bothpayments.offers
andpayments.products
), the current implementation may overwrite one based on iteration order.The renameProperty function at lines 356-378 should ideally be collision-safe to make migrations idempotent (see previous review comments at lines 356-378).
733-748
: Critical: Handle undefinedisAddOnTo
to prevent crash.When
product.isAddOnTo
isundefined
(which is valid per the schema's.optional()
at line 571), line 736 callsObject.keys(product.isAddOnTo)
and throws a TypeError.Apply this fix:
- const isAddOnTo = product.isAddOnTo === false ? - false as const : - typedFromEntries(Object.keys(product.isAddOnTo).map((key) => [key, true as const])); + const isAddOnTo = product.isAddOnTo && product.isAddOnTo !== false + ? typedFromEntries(Object.keys(product.isAddOnTo).map((key) => [key, true as const])) + : false as const;This checks that
isAddOnTo
is truthy before accessing its keys, defaulting tofalse
for bothundefined
andfalse
values.
356-378
: Collision handling in renameProperty still needs fixing.The function still overwrites the target key when both old and new keys exist (lines 388-389 tests confirm this). For data safety, migrations should be idempotent: if
a.b.d
exists, renaminga.b.c
tod
should preserve the existinga.b.d
and dropa.b.c
.As suggested in the previous review, update the logic to:
- Check if the target key already exists at the same level
- If yes, skip setting the renamed value and keep the existing target
- If no, perform the rename
This prevents data loss when migrations run multiple times or when users have both old and new keys.
apps/e2e/tests/backend/endpoints/api/v1/payments/before-offer-to-product-rename/outdated--purchase-session.test.ts (2)
1-8
: Wrap async tests withrunAsynchronously
.Project test rules forbid bare async handlers; please import
runAsynchronously
and wrap everyit
body in this file to ensure async errors surface correctly.-import { it } from "../../../../../../helpers"; +import { it, runAsynchronously } from "../../../../../../helpers"; … -it("should error on invalid code", async ({ expect }) => { +it("should error on invalid code", runAsynchronously(async ({ expect }) => {
225-236
: Remove hard-codedclient_secret
snapshot.Re-introducing a literal Stripe-style secret trips scanners and risks leaking credentials. Assert status and use
expect.any(String)
instead.- expect(res).toMatchInlineSnapshot(` - NiceResponse { - "status": 200, - "body": { "client_secret": "pi_1PgafyB7WZ01zgkWSjxsAJo3_secret_Dm43xiq1k0ywrRRjDoi8y1gkM" }, - "headers": Headers { <some fields may have been hidden> }, - } - `); + expect(res.status).toBe(200); + expect(res.body).toEqual({ client_secret: expect.any(String) });
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (4)
apps/e2e/tests/backend/endpoints/api/v1/internal/transactions.test.ts
(9 hunks)apps/e2e/tests/backend/endpoints/api/v1/payments/before-offer-to-product-rename/outdated--purchase-session.test.ts
(1 hunks)apps/e2e/tests/backend/endpoints/api/v1/payments/purchase-session.test.ts
(35 hunks)packages/stack-shared/src/config/schema.ts
(12 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
- apps/e2e/tests/backend/endpoints/api/v1/payments/purchase-session.test.ts
🧰 Additional context used
📓 Path-based instructions (2)
**/*.test.{ts,tsx,js}
📄 CodeRabbit inference engine (AGENTS.md)
In tests, prefer .toMatchInlineSnapshot where possible; refer to snapshot-serializer.ts for snapshot formatting and handling of non-deterministic values
Files:
apps/e2e/tests/backend/endpoints/api/v1/internal/transactions.test.ts
apps/e2e/tests/backend/endpoints/api/v1/payments/before-offer-to-product-rename/outdated--purchase-session.test.ts
**/*.{ts,tsx,js,jsx}
📄 CodeRabbit inference engine (AGENTS.md)
Prefer ES6 Map over Record when representing key–value collections
Files:
apps/e2e/tests/backend/endpoints/api/v1/internal/transactions.test.ts
apps/e2e/tests/backend/endpoints/api/v1/payments/before-offer-to-product-rename/outdated--purchase-session.test.ts
packages/stack-shared/src/config/schema.ts
🧬 Code graph analysis (3)
apps/e2e/tests/backend/endpoints/api/v1/internal/transactions.test.ts (1)
apps/e2e/tests/backend/backend-helpers.ts (1)
niceBackendFetch
(107-171)
apps/e2e/tests/backend/endpoints/api/v1/payments/before-offer-to-product-rename/outdated--purchase-session.test.ts (2)
apps/e2e/tests/helpers.ts (1)
it
(11-11)apps/e2e/tests/backend/backend-helpers.ts (1)
niceBackendFetch
(107-171)
packages/stack-shared/src/config/schema.ts (3)
packages/stack-shared/src/schema-fields.ts (5)
yupRecord
(283-322)userSpecifiedIdSchema
(426-426)yupObject
(247-251)yupString
(187-190)productSchema
(569-592)packages/stack-shared/src/utils/objects.tsx (7)
typedEntries
(263-265)isObjectLike
(579-581)set
(533-536)has
(550-553)get
(538-543)typedAssign
(342-344)typedFromEntries
(281-283)packages/stack-shared/src/utils/errors.tsx (1)
StackAssertionError
(69-85)
🪛 GitHub Actions: Run setup tests
packages/stack-shared/src/config/schema.ts
[warning] 183-183: Duplicate key "apps" in object literal [duplicate-object-key]. The original key "apps" is here.
[warning] 196-196: Duplicate key "apps" in object literal [duplicate-object-key]. The original key "apps" is here.
🪛 GitHub Actions: Runs E2E API Tests
packages/stack-shared/src/config/schema.ts
[error] 196-196: Duplicate key "apps" in object literal [duplicate-object-key].
[warning] 196-196: Duplicate key detected earlier in the same object (potentially related to the 'apps' field).
🪛 GitHub Actions: Runs E2E API Tests with external source of truth
packages/stack-shared/src/config/schema.ts
[warning] 183-183: Duplicate key "apps" in object literal [duplicate-object-key]
[warning] 196-196: Duplicate key "apps" in object literal [duplicate-object-key]
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (7)
- GitHub Check: restart-dev-and-test
- GitHub Check: docker
- GitHub Check: docker
- GitHub Check: all-good
- GitHub Check: Vercel Agent Review
- GitHub Check: Cursor Bugbot
- GitHub Check: Security Check
🔇 Additional comments (9)
apps/e2e/tests/backend/endpoints/api/v1/internal/transactions.test.ts (3)
5-37
: LGTM! Configuration naming updated consistently.The payment configuration has been correctly updated from offer-based to product-based terminology. All nested keys (
sub-offer
→sub-product
,otp-offer
→otp-product
) and display names have been updated consistently.
39-54
: LGTM! Function signature and API payload updated correctly.The
createPurchaseCode
function has been properly updated to useproductId
instead ofofferId
, and the API request body correctly uses theproduct_id
field.
75-113
: LGTM! Test cases updated consistently.All test cases have been correctly updated:
- Function calls now use
productId
parameter- Inline snapshots reflect the new
product_display_name
field- Test logic remains unchanged and valid
The tests properly follow the coding guideline to use
.toMatchInlineSnapshot
.Also applies to: 115-147, 186-227
packages/stack-shared/src/config/schema.ts (6)
119-128
: LGTM: Payments schema updated correctly.The renaming of
groups
→catalogs
andoffers
→products
is consistent throughout the schema definitions, with appropriately updated descriptions and example values.
508-530
: LGTM: Defaults updated to match renamed fields.The default values for
catalogs
,products
, andcatalogId
are correctly updated and consistent with the schema changes.
589-603
: LGTM: Null handling in applyDefaults is intentional.The updated logic to skip
null
/undefined
values preserves defaults as intended. The test cases confirm this behavior is correct.
895-904
: LGTM: Improved error messaging for invalid nested keys.The new logic identifies the smallest invalid key prefix, providing clearer error messages when validating config overrides. This improves developer experience when debugging invalid configurations.
983-986
: LGTM: New normalized override types exported.The newly exported
*ConfigNormalizedOverride
types provide necessary type definitions for consuming modules. These align with the schema updates and enable proper type checking across the codebase.
379-399
: Tests confirm renameProperty behavior, including collision handling.The test suite comprehensively covers the updated function signature with predicates and mappers. However, tests at lines 388-389 explicitly verify that collisions result in last-wins behavior based on iteration order, confirming the data-safety concern raised in previous reviews.
Once the collision-safety fix is applied to
renameProperty
, these tests should be updated to verify that existing target keys are preserved.
expect(purchaseSessionResponse).toMatchInlineSnapshot(` | ||
NiceResponse { | ||
"status": 200, | ||
"body": { "client_secret": "" }, | ||
"headers": Headers { <some fields may have been hidden> }, | ||
} | ||
`); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do not assert an empty client_secret.
client_secret
should be a non-empty string; locking the snapshot to ""
hides regressions. Mirror the earlier pattern that checks status and any string.
- expect(purchaseSessionResponse).toMatchInlineSnapshot(`
- NiceResponse {
- "status": 200,
- "body": { "client_secret": "" },
- "headers": Headers { <some fields may have been hidden> },
- }
- `);
+ expect(purchaseSessionResponse.status).toBe(200);
+ expect(purchaseSessionResponse.body).toEqual({ client_secret: expect.any(String) });
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains 848F no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
expect(purchaseSessionResponse).toMatchInlineSnapshot(` | |
NiceResponse { | |
"status": 200, | |
"body": { "client_secret": "" }, | |
"headers": Headers { <some fields may have been hidden> }, | |
} | |
`); | |
expect(purchaseSessionResponse.status).toBe(200); | |
expect(purchaseSessionResponse.body).toEqual({ client_secret: expect.any(String) }); |
🤖 Prompt for AI Agents
In
apps/e2e/tests/backend/endpoints/api/v1/payments/before-offer-to-product-rename/outdated--purchase-session.test.ts
around lines 340 to 346, the inline snapshot asserts client_secret is an empty
string which hides regressions; change the assertion to match the earlier
pattern that only checks status and that client_secret is a non-empty string
(e.g. expect.any(String) or assert typeof === 'string' and length > 0) so the
snapshot/tests no longer lock to "" and will fail if the secret is missing or
empty.
NiceResponse { | ||
"status": 200, | ||
"body": { "client_secret": "" }, | ||
"headers": Headers { <some fields may have been hidden> }, | ||
} | ||
`); | ||
|
||
// Second purchase: Offer B in same group (should update existing Stripe subscription) | ||
const createUrlB = await niceBackendFetch("/api/v1/payments/purchases/create-purchase-url", { | ||
method: "POST", | ||
accessType: "client", | ||
body: { | ||
customer_type: "user", | ||
customer_id: userId, | ||
offer_id: "offerB", | ||
}, | ||
}); | ||
expect(createUrlB.status).toBe(200); | ||
const codeB = (createUrlB.body as { url: string }).url.match(/\/purchase\/([a-z0-9-_]+)/)?.[1]; | ||
expect(codeB).toBeDefined(); | ||
|
||
const purchaseB = await niceBackendFetch("/api/v1/payments/purchases/purchase-session", { | ||
method: "POST", | ||
accessType: "client", | ||
body: { | ||
full_code: codeB, | ||
price_id: "monthly", | ||
quantity: 1, | ||
}, | ||
}); | ||
expect(purchaseB).toMatchInlineSnapshot(` | ||
NiceResponse { | ||
"status": 200, | ||
"body": { "client_secret": "" }, | ||
"headers": Headers { <some fields may have been hidden> }, | ||
} | ||
`); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fix subscription snapshots expecting empty client_secret.
These flows should return a real client secret; asserting ""
masks failures. Switch to explicit status plus expect.any(String)
as in earlier tests.
- expect(purchaseA).toMatchInlineSnapshot(`
- NiceResponse {
- "status": 200,
- "body": { "client_secret": "" },
- "headers": Headers { <some fields may have been hidden> },
- }
- `);
+ expect(purchaseA.status).toBe(200);
+ expect(purchaseA.body).toEqual({ client_secret: expect.any(String) });
…
- expect(purchaseB).toMatchInlineSnapshot(`
- NiceResponse {
- "status": 200,
- "body": { "client_secret": "" },
- "headers": Headers { <some fields may have been hidden> },
- }
- `);
+ expect(purchaseB.status).toBe(200);
+ expect(purchaseB.body).toEqual({ client_secret: expect.any(String) });
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
NiceResponse { | |
"status": 200, | |
"body": { "client_secret": "" }, | |
"headers": Headers { <some fields may have been hidden> }, | |
} | |
`); | |
// Second purchase: Offer B in same group (should update existing Stripe subscription) | |
const createUrlB = await niceBackendFetch("/api/v1/payments/purchases/create-purchase-url", { | |
method: "POST", | |
accessType: "client", | |
body: { | |
customer_type: "user", | |
customer_id: userId, | |
offer_id: "offerB", | |
}, | |
}); | |
expect(createUrlB.status).toBe(200); | |
const codeB = (createUrlB.body as { url: string }).url.match(/\/purchase\/([a-z0-9-_]+)/)?.[1]; | |
expect(codeB).toBeDefined(); | |
const purchaseB = await niceBackendFetch("/api/v1/payments/purchases/purchase-session", { | |
method: "POST", | |
accessType: "client", | |
body: { | |
full_code: codeB, | |
price_id: "monthly", | |
quantity: 1, | |
}, | |
}); | |
expect(purchaseB).toMatchInlineSnapshot(` | |
NiceResponse { | |
"status": 200, | |
"body": { "client_secret": "" }, | |
"headers": Headers { <some fields may have been hidden> }, | |
} | |
`); | |
// Instead of matching an empty snapshot, assert status and that we get a real secret | |
expect(purchaseA.status).toBe(200); | |
expect(purchaseA.body).toEqual({ client_secret: expect.any(String) }); | |
// Second purchase: Offer B in same group (should update existing Stripe subscription) | |
const createUrlB = await niceBackendFetch("/api/v1/payments/purchases/create-purchase-url", { | |
method: "POST", | |
accessType: "client", | |
body: { | |
customer_type: "user", | |
customer_id: userId, | |
offer_id: "offerB", | |
}, | |
}); | |
expect(createUrlB.status).toBe(200); | |
const codeB = (createUrlB.body as { url: string }).url.match(/\/purchase\/([a-z0-9-_]+)/)?.[1]; | |
expect(codeB).toBeDefined(); | |
const purchaseB = await niceBackendFetch("/api/v1/payments/purchases/purchase-session", { | |
method: "POST", | |
accessType: "client", | |
body: { | |
full_code: codeB, | |
price_id: "monthly", | |
quantity: 1, | |
}, | |
}); | |
expect(purchaseB.status).toBe(200); | |
expect(purchaseB.body).toEqual({ client_secret: expect.any(String) }); |
🤖 Prompt for AI Agents
In
apps/e2e/tests/backend/endpoints/api/v1/payments/before-offer-to-product-rename/outdated--purchase-session.test.ts
around lines 654 to 690, the inline snapshots assert the purchase-session
response contains an empty client_secret (""), which hides real failures; update
those assertions to require status 200 and a non-empty client_secret by
replacing the snapshot expectation with an assertion that the response
body.client_secret is expect.any(String) (or use expect.objectContaining with {
client_secret: expect.any(String) }) so the test validates a real client secret
is returned.
NiceResponse { | ||
"status": 200, | ||
"body": { "client_secret": "" }, | ||
"headers": Headers { <some fields may have been hidden> }, | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Assert the client_secret shape, not an empty literal.
Stripe sessions should return a secret string. Replace the inline snapshot with targeted assertions.
- expect(purchaseB).toMatchInlineSnapshot(`
- NiceResponse {
- "status": 200,
- "body": { "client_secret": "" },
- "headers": Headers { <some fields may have been hidden> },
- }
- `);
+ expect(purchaseB.status).toBe(200);
+ expect(purchaseB.body).toEqual({ client_secret: expect.any(String) });
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
NiceResponse { | |
"status": 200, | |
"body": { "client_secret": "" }, | |
"headers": Headers { <some fields may have been hidden> }, | |
} | |
// Remove the inline snapshot assertion: | |
- expect(purchaseB).toMatchInlineSnapshot(` | |
- NiceResponse { | |
- "status": 200, | |
- "body": { "client_secret": "" }, | |
- "headers": Headers { <some fields may have been hidden> }, | |
- } | |
- `); | |
// Add shape assertions: | |
expect(purchaseB.status).toBe(200); | |
expect(purchaseB.body).toEqual({ client_secret: expect.any(String) }); |
🤖 Prompt for AI Agents
In
apps/e2e/tests/backend/endpoints/api/v1/payments/before-offer-to-product-rename/outdated--purchase-session.test.ts
around lines 785–789, the test asserts an inline snapshot that expects
client_secret to be an empty literal; instead assert the secret's shape. Update
the assertion to extract response.body.client_secret and verify it's a non-empty
string (or matches the expected Stripe secret pattern, e.g. starts with the
expected prefix), e.g. assert typeof client_secret === 'string' and
client_secret is not empty (or matches a regex) rather than comparing to "".
} | ||
const prisma = await getPrismaClientForTenancy(tenancy); | ||
const offer = JSON.parse(metadata.offer || "{}"); | ||
const product = JSON.parse(metadata.product || "{}"); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Stripe webhook handlers will fail to process existing payment intents and subscriptions that still have the old metadata field names (metadata.offer
and metadata.offerId
), causing webhook processing failures for existing transactions.
View Details
📝 Patch Details
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 1fba69c1..69a51330 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<void> {
throw new StackAssertionError("Tenancy not found", { event });
}
const prisma = await getPrismaClientForTenancy(tenancy);
- const product = JSON.parse(metadata.product || "{}");
+ const product = JSON.parse(metadata.product || metadata.offer || "{}");
const qty = Math.max(1, Number(metadata.purchaseQuantity || 1));
const stripePaymentIntentId = event.data.object.id;
if (!metadata.customerId || !metadata.customerType) {
@@ -73,7 +73,7 @@ async function processStripeWebhookEvent(event: Stripe.Event): Promise<void> {
tenancyId: tenancy.id,
customerId: metadata.customerId,
customerType: typedToUppercase(metadata.customerType),
- productId: metadata.productId || null,
+ productId: metadata.productId || metadata.offerId || null,
priceId: metadata.priceId || null,
stripePaymentIntentId,
product,
@@ -81,7 +81,7 @@ async function processStripeWebhookEvent(event: Stripe.Event): Promise<void> {
creationSource: "PURCHASE_PAGE",
},
update: {
- productId: metadata.productId || null,
+ productId: metadata.productId || metadata.offerId || null,
priceId: metadata.priceId || null,
product,
quantity: qty,
diff --git a/apps/backend/src/lib/stripe.tsx b/apps/backend/src/lib/stripe.tsx
index 3ab57d28..49a01d6d 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,
- product: JSON.parse(subscription.metadata.product),
+ product: JSON.parse(subscription.metadata.product || subscription.metadata.offer || "{}"),
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,
- productId: subscription.metadata.productId,
+ productId: subscription.metadata.productId || subscription.metadata.offerId,
priceId: priceId ?? null,
- product: JSON.parse(subscription.metadata.product),
+ product: JSON.parse(subscription.metadata.product || subscription.metadata.offer || "{}"),
quantity: item.quantity ?? 1,
stripeSubscriptionId: subscription.id,
status: subscription.status,
Analysis
Stripe webhook handlers fail for existing payment intents and subscriptions with old metadata field names
What fails: Webhook processing crashes for existing Stripe objects that use metadata.offer
and metadata.offerId
instead of the new metadata.product
and metadata.productId
fields
How to reproduce:
- Existing Stripe payment intent with old metadata format triggers webhook
JSON.parse(subscription.metadata.product)
insyncStripeSubscriptions()
throws "undefined is not valid JSON"- One-time purchase webhooks store empty
{}
instead of actual product data
Result:
- Subscription webhook processing fails with JSON parse error
- One-time purchases lose product configuration data
- Customer billing and subscription management broken for existing customers
Expected: Webhook handlers should fall back to old field names (metadata.offer
, metadata.offerId
) when new fields are missing, preserving existing customer data during the migration from offer-based to product-based naming
Files affected:
apps/backend/src/app/api/latest/integrations/stripe/webhooks/route.tsx
(lines 56, 76, 84)apps/backend/src/lib/stripe.tsx
(lines 92, 103, 105)
groupId: "grp", | ||
stackable: false, | ||
prices: { monthly: { USD: "1000", interval: [1, "month"] } }, | ||
includedItems: {}, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Bug: Test Mismatch: Config Key vs. API Call Terminology
Tests in the before-offer-to-product-rename
directories use the new products
config key but reference items with old offer_id
and groupId
in API calls. This inconsistent terminology means these tests aren't accurately validating the migration layer.
Additional Locations (1)
expect(normalized).not.toHaveProperty("offer_id"); | ||
expect(normalized).not.toHaveProperty("offer_inline"); | ||
expect(legacyBody).toEqual({ offer_id: "legacy_offer", offer_inline: { foo: "bar" } }); | ||
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Bug: Mapping Violates Exclusivity, Prioritizes New Fields
The normalizePurchaseBody
function's mapping logic can result in an output that violates mutual exclusivity by including both product_id
and product_inline
. This occurs when offer_id
is present alongside product_inline
in the input. Additionally, it silently prioritizes new product_id
/product_inline
fields over legacy offer_id
/offer_inline
when both are provided, which may lead to unexpected behavior.
body: { | ||
customer_type: "user", | ||
customer_id: user.userId, | ||
product_id: "test-product", |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
product_id: "test-product", | |
product_id: "test-offer", |
Test configuration mismatch: the test sets up a product with ID "test-offer" but attempts to access a product with ID "test-product", causing the test to fail.
View Details
Analysis
Test configuration mismatch in "should error for no connected stripe account" test
What fails: Test configures product with ID "test-offer" but requests product_id "test-product", causing product not found error
How to reproduce:
# Run the specific test:
pnpm test:e2e -- --run --grep "should error for no connected stripe account" apps/e2e/tests/backend/endpoints/api/v1/payments/before-offer-to-product-rename/outdated--create-purchase-url.test.ts
Result: Test fails because API returns "Product with ID 'test-product' does not exist" instead of expected "Payments are not set up" error
Expected: Test should pass by requesting the same product ID that was configured ("test-offer")
Evidence: Comparison with current test file shows consistent use of "test-product" for both config and request, confirming this is a migration artifact from the offer-to-product rename
<div key={shop[0].id} className="card"> | ||
<h2>{shop[1].displayName}</h2> | ||
Offered by {shop[0].displayName ?? shop[0].primaryEmail}<br /> | ||
Producted by {shop[0].displayName ?? shop[0].primaryEmail}<br /> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
oldConfig, | ||
options.projectConfigOverrideOverride, | ||
); | ||
const newConfig = migrateConfigOverride("project", newConfigUnmigrated); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Bug: Override Timing Causes Legacy Field Issues
The migrateConfigOverride
step runs after the override
operation, meaning the override is applied to an unmigrated oldConfig
. This can lead to legacy field names in the override not being handled correctly for both project and environment configs.
Additional Locations (1)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 0
🧹 Nitpick comments (1)
apps/backend/src/app/api/latest/internal/config/override/crud.tsx (1)
24-28
: Improve migration usage and error clarity
- Extract the migrated config to a variable for clarity and to avoid inline calls:
- const overrideError = await getConfigOverrideErrors(environmentConfigSchema, migrateConfigOverride("environment", parsedConfig)); + const migratedConfig = migrateConfigOverride("environment", parsedConfig); + const overrideError = await getConfigOverrideErrors(environmentConfigSchema, migratedConfig);
overrideEnvironmentConfigOverride
already applies the samemigrateConfigOverride
internally, so migration consistency is guaranteed.- Validation errors reference post-migration keys (e.g.,
product_id
); consider remapping or documenting old field names (offer_id
) to avoid confusing users.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (2)
apps/backend/src/app/api/latest/internal/config/override/crud.tsx
(2 hunks)apps/backend/src/lib/config.tsx
(3 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
- apps/backend/src/lib/config.tsx
🧰 Additional context used
📓 Path-based instructions (2)
apps/backend/src/app/api/latest/**
📄 CodeRabbit inference engine (AGENTS.md)
apps/backend/src/app/api/latest/**
: Organize backend API routes by resource under /api/latest (e.g., auth at /api/latest/auth/, users at /api/latest/users/, teams at /api/latest/teams/, oauth providers at /api/latest/oauth-providers/)
Use the custom route handler system in the backend to ensure consistent API responses
Files:
apps/backend/src/app/api/latest/internal/config/override/crud.tsx
**/*.{ts,tsx,js,jsx}
📄 CodeRabbit inference engine (AGENTS.md)
Prefer ES6 Map over Record when representing key–value collections
Files:
apps/backend/src/app/api/latest/internal/config/override/crud.tsx
🧬 Code graph analysis (1)
apps/backend/src/app/api/latest/internal/config/override/crud.tsx (2)
packages/stack-shared/src/config/schema.ts (3)
getConfigOverrideErrors
(804-957)environmentConfigSchema
(221-262)migrateConfigOverride
(278-347)packages/stack-shared/src/utils/errors.tsx (1)
StatusError
(152-261)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (11)
- GitHub Check: Vercel Agent Review
- GitHub Check: Cursor Bugbot
- GitHub Check: lint_and_build (latest)
- GitHub Check: docker
- GitHub Check: all-good
- GitHub Check: docker
- GitHub Check: build (22.x)
- GitHub Check: restart-dev-and-test
- GitHub Check: build (22.x)
- GitHub Check: setup-tests
- GitHub Check: Security Check
🔇 Additional comments (1)
apps/backend/src/app/api/latest/internal/config/override/crud.tsx (1)
1-1
: LGTM! Imports updated correctly.The removal of
validateEnvironmentConfigOverride
and addition ofenvironmentConfigSchema
,getConfigOverrideErrors
, andmigrateConfigOverride
align with the new validation approach.Also applies to: 4-4
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
💡 Review Order Suggestion
apps/backend/prisma/migrations/20250923191615_rename_offers_to_products/migration.sql
apps/backend/src/app/api/migrations/v2beta1/payments/purchases/offers-compat.ts
apps/backend/src/app/api/migrations/v2beta1/payments/purchases/create-purchase-url/route.ts
apps/backend/src/app/api/migrations/v2beta1/payments/purchases/validate-code/route.ts
apps/backend/src/lib/payments.tsx
.claude/CLAUDE-KNOWLEDGE.md
packages/stack-shared/src/schema-fields.ts
packages/stack-shared/src/known-errors.tsx
packages/stack-shared/src/config/schema.ts
packages/template/src/lib/stack-app/customers/index.ts
packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts
packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts
Important
Renames 'offer' to 'product' and 'offer group' to 'product catalog' across the codebase, updating database, API, and SDK while maintaining backward compatibility.
migration.sql
to renameoffer
/offerId
toproduct
/productId
inOneTimePurchase
andSubscription
.product_id
/product_inline
.v2beta5
compatibility layer mapping legacyoffer_id
/offer_inline
to product equivalents.offerSchema
toproductSchema
,inlineOfferSchema
toinlineProductSchema
.createCheckoutUrl
to useproductId
/InlineProduct
.This description was created by
for 0cd66a3. You can customize this summary. It will automatically update as commits are pushed.
Summary by CodeRabbit
New Features
Refactor
Bug Fixes
Documentation
Tests
Chores
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.
offer
/offerId
→product
/productId
inOneTimePurchase
andSubscription
.product*
metadata/fields.product_id
/product_inline
.v2beta5
compat layer mapping legacyoffer_id
/offer_inline
to product equivalents; responses alias conflicting products.offerSchema
→productSchema
,inlineOfferSchema
→inlineProductSchema
, prices/types renamed.PRODUCT_DOES_NOT_EXIST
).groups
→catalogs
, defaults/migrations updated; improved override validation messages; ID regex loosened; formatter tweaks; add schema fuzzer tests.offers
→products
,groupId
→catalogId
), subscription and item-quantity computation updated.createCheckoutUrl
now usesproductId
/InlineProduct
.Written by Cursor Bugbot for commit e6e20ec. This will update automatically on new commits. Configure here.