Skip to content

Conversation

@BilalG1
Copy link
Contributor

@BilalG1 BilalG1 commented Aug 20, 2025


Important

Introduces 'custom' customer type support across API, models, and tests, updating methods and validation schemas accordingly.

  • New Features:
    • Added 'custom' customer type support in schema.prisma, payments.tsx, and server-app-impl.ts.
  • API Changes:
    • Updated API endpoints in route.ts files to include customer_type parameter.
    • Modified createPurchaseUrl and updateItemQuantity methods to handle 'custom' type.
  • Models:
    • Added 'CUSTOM' to CustomerType enum in schema.prisma.
    • Changed customerId type to TEXT in Subscription and ItemQuantityChange models.
  • Validation:
    • Updated customerTypeSchema in schema-fields.ts to include 'custom'.
  • Tests:
    • Added and updated tests in payments.test.ts and items.test.ts for 'custom' type scenarios.
  • Misc:
    • Removed createPurchaseUrl method from admin-interface.ts.
    • Updated client-app-impl.ts and server-app-impl.ts to support 'custom' type in item-related methods.

This description was created by Ellipsis for 509762d. You can customize this summary. It will automatically update as commits are pushed.


Summary by CodeRabbit

  • New Features

    • Added universal "custom" customer type; test-mode purchase flow for admins.
  • API Changes

    • Routes and payloads now include customer_type and accept non‑UUID customer identifiers; validation and error responses updated.
  • Breaking Changes

    • SDK/Admin interfaces updated (new payload shapes, removed createPurchaseUrl, added testModePurchase); item quantity routes now include customer_type.
  • Dashboard/UI

    • New Offers and Items pages, dialogs, and ConnectPayments integration; customerType options include "custom".
  • Tests

    • E2E and unit tests updated/added for custom and test‑mode flows.

@vercel
Copy link

vercel bot commented Aug 20, 2025

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Preview Comments Updated (UTC)
stack-backend Ready Ready Preview Comment Aug 25, 2025 6:06pm
stack-dashboard Canceled Canceled Aug 25, 2025 6:06pm
stack-demo Ready Ready Preview Comment Aug 25, 2025 6:06pm
stack-docs Ready Ready Preview Comment Aug 25, 2025 6:06pm

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Aug 20, 2025

Note

Other AI code review bot(s) detected

CodeRabbit 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.

Walkthrough

Adds a "custom" customer type across DB, Prisma schema, backend payments logic, routes, SDKs, dashboard UI, templates and tests; threads customer_type through APIs, normalizes persisted customerType to uppercase, relaxes UUID constraints on several customerId fields, and adds test-mode purchase session handling.

Changes

Cohort / File(s) Summary
Prisma migrations & schema
apps/backend/prisma/migrations/.../migration.sql, apps/backend/prisma/schema.prisma
Add CUSTOM to CustomerType; add SubscriptionCreationSource enum and Subscription.creationSource; add NOT NULL ItemQuantityChange.customerType; change ItemQuantityChange.customerId and Subscription.customerId from UUID DB type to plain text; make stripeSubscriptionId nullable.
Backend payments library
apps/backend/src/lib/payments.tsx
Remove helpers (ensureItemCustomerTypeMatches, ensureOfferCustomerTypeMatches, getCustomerType); require customerType in getItemQuantityForCustomer; add ensureCustomerExists; normalize/filter by uppercase customerType; support "custom".
Items routes (read & update)
apps/backend/src/app/api/latest/payments/items/[customer_type]/[customer_id]/[item_id]/route.ts, .../update-quantity/route.ts
Introduce customer_type route param (`"user"
Purchase flow & test-mode
apps/backend/src/app/api/latest/payments/purchases/create-purchase-url/route.ts, .../purchase-session/route.tsx, .../validate-code/route.ts, apps/backend/src/app/api/latest/internal/payments/test-mode-purchase-session/route.tsx
Add customer_type to create-purchase-url payload and validate offer/customer-type inline; require tenancy validation when validating/redeeming codes; add hidden admin test-mode-purchase-session route to create subscriptions with creationSource TEST_MODE and revoke codes.
Dashboard UI & components
apps/dashboard/src/.../payments/*, apps/dashboard/src/components/payments/*, apps/dashboard/src/components/data-table/*, apps/dashboard/src/components/dialog-opener.tsx
Add Item/Offer pages, dialogs and ActionsCells; expose "custom" in customer-type selects and schemas; ItemDialog/OfferDialog added; PaymentItemTable and PaymentOfferTable adjusted (removed toolbarRender), create/update/delete flows wired; relax customerId UUID validation and map payload to `{ userId
SDKs / Shared interfaces
packages/stack-shared/src/interface/*, packages/template/src/lib/stack-app/...
Client/getItem and createCheckoutUrl signatures updated to include customer_type and union options (`userId
Shared schema & errors
packages/stack-shared/src/schema-fields.ts, packages/stack-shared/src/known-errors.tsx
Extend customerTypeSchema to include "custom"; require offer.customerType; update KnownErrors constructors/details to support "custom" in item/offer mismatch errors.
E2E + JS tests
apps/e2e/tests/backend/*, apps/e2e/tests/js/*
Update tests to include customer_type in payloads, change item endpoints to /items/{customer_type}/{customer_id}/{item_id}, adapt admin quantity-change calls to teamId/customCustomerId, add custom-type and test-mode tests, and update snapshots/timeouts.
Dashboard UX & utils
apps/dashboard/.../purchase/*, apps/dashboard/src/lib/utils.tsx, tailwind.config.ts
Add admin test-mode bypass UI and flow, bypass handling in return page, use parseJson for env parsing, and add fade-in animation in Tailwind config.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  participant SDK as Client/SDK
  participant API as Items API
  participant Payments as payments lib
  participant DB as Prisma/DB
  SDK->>API: GET /payments/items/{customer_type}/{customer_id}/{item_id}
  API->>DB: fetch itemConfig(item_id)
  alt item not found
    API-->>SDK: 404
  else item found
    alt req.customer_type != itemConfig.customerType
      API-->>SDK: KnownErrors.ItemCustomerTypeDoesNotMatch (400)
    else match
      API->>Payments: getItemQuantityForCustomer(itemId, customerId, customerType)
      Payments->>DB: query subscriptions & ItemQuantityChange where customerType = UPPER(customerType) and customerId = ...
      DB-->>Payments: aggregated quantity
      Payments-->>API: quantity
      API-->>SDK: 200 { quantity }
    end
  end
Loading
sequenceDiagram
  autonumber
  participant SDK as Client/SDK
  participant API as Update Quantity API
  participant Payments as payments lib
  participant DB as Prisma/DB
  SDK->>API: POST /payments/items/{customer_type}/{customer_id}/{item_id}/update-quantity
  API->>DB: fetch itemConfig(item_id)
  alt mismatch
    API-->>SDK: KnownErrors.ItemCustomerTypeDoesNotMatch
  else match
    API->>Payments: ensureCustomerExists(tenancyId, customerType, customerId)
    Payments->>DB: validate existence (user/team/custom) or throw KnownErrors.UserNotFound/TeamNotFound
    Payments->>DB: tx create ItemQuantityChange { customerType: UPPER(customer_type), customerId, delta, ... }
    DB-->>Payments: committed
    Payments-->>API: success
    API-->>SDK: 204
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested reviewers

  • N2D4

Poem

I’m a rabbit in the code-lined glen, I nibble types and hop through trees,
A "custom" burrow joins the "user" and "team" with ease.
UUIDs loosen, routes now wear threefold hats,
Quantities update, tests cheer, and dialogs swing their bats.
I twitch my whiskers — new paths bloom beneath the keys. 🐇✨

Tip

🔌 Remote MCP (Model Context Protocol) integration is now available!

Pro plan users can now connect to remote MCP servers from the Integrations page. Connect with popular remote MCPs such as Notion and Linear to add more context to your reviews and chats.

✨ Finishing Touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch custom-item-customers

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.

❤️ Share
🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

CodeRabbit Commands (Invoked using PR/Issue comments)

Type @coderabbitai help to get the list of available commands.

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Status, Documentation and Community

  • Visit our Status Page to check the current availability of CodeRabbit.
  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

Copy link
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Greptile Summary

This PR implements support for custom customer types in Stack's payment system, expanding the existing user/team-based model to include arbitrary custom customer entities. The changes introduce a new 'custom' customer type alongside the existing 'user' and 'team' types throughout the payment infrastructure.

Key architectural changes include:

Database Schema Updates: A new migration adds a CUSTOM value to the CustomerType enum and introduces a required customerType column to the ItemQuantityChange table. The customerId fields in both ItemQuantityChange and Subscription tables are converted from UUID to TEXT to accommodate non-UUID custom identifiers.

API Route Restructuring: Payment API endpoints have been refactored from /payments/items/{customer_id}/{item_id} to /payments/items/{customer_type}/{customer_id}/{item_id}, making customer type explicit in the URL structure. This provides better validation and routing capabilities.

Client/Server Interface Extensions: Both client and server interfaces now support discriminated union types for item operations, allowing methods like getItem() and updateItemQuantity() to accept either { userId: string }, { teamId: string }, or { customId: string } parameters.

Caching and State Management: New custom item caches (_serverCustomItemsCache, _customItemCache) have been added to handle custom customer items separately from user and team items, maintaining performance while supporting the new customer type.

Error Handling: Error constructors for ItemCustomerTypeDoesNotMatch and OfferCustomerTypeDoesNotMatch have been extended to include the 'custom' type, ensuring proper validation and meaningful error messages.

The implementation maintains backward compatibility while enabling use cases where payment customers don't correspond to Stack's built-in user/team entities, such as external customer management systems or custom business models.

Confidence score: 2/5

  • This PR introduces breaking changes to core payment functionality with potential data migration risks and incomplete error handling
  • Score reflects concerns about missing validation functions, incomplete Stripe integration for custom customers, and database migration risks with NOT NULL column additions
  • Pay close attention to the database migration file and Stripe customer metadata handling in payment routes

24 files reviewed, 2 comments

Edit Code Review Bot Settings | Greptile

@recurseml
Copy link

recurseml bot commented Aug 20, 2025

Review by RecurseML

🔍 Review performed on b0e7706..d6397fa

Severity Location Issue
Medium apps/e2e/tests/js/payments.test.ts:147 REST API parameter should use snake_case naming convention
Medium apps/e2e/tests/js/payments.test.ts:192 REST API parameter should use snake_case naming convention
Medium apps/e2e/tests/js/payments.test.ts:103 REST API parameter should use snake_case naming convention
Medium apps/e2e/tests/js/payments.test.ts:84 REST API parameter should use snake_case naming convention
Medium apps/e2e/tests/js/payments.test.ts:72 REST API parameter should use snake_case naming convention
Medium apps/e2e/tests/js/payments.test.ts:101 REST API parameter should use snake_case naming convention
Medium apps/e2e/tests/js/payments.test.ts:104 REST API parameter should use snake_case naming convention
Medium packages/stack-shared/src/interface/client-interface.ts:1795 Direct JSON.stringify usage violates code pattern guidelines
✅ Files analyzed, no issues (3)

packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts
apps/e2e/tests/backend/endpoints/api/v1/payments/items.test.ts
packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts

⏭️ Files skipped (low suspicion) (20)

apps/backend/prisma/migrations/20250820164831_custom_customer_types/migration.sql
apps/backend/prisma/schema.prisma
apps/backend/src/app/api/latest/payments/items/[customer_type]/[customer_id]/[item_id]/route.ts
apps/backend/src/app/api/latest/payments/items/[customer_type]/[customer_id]/[item_id]/update-quantity/route.ts
apps/backend/src/app/api/latest/payments/purchases/create-purchase-url/route.ts
apps/backend/src/app/api/latest/team-invitations/accept/verification-code-handler.tsx
apps/backend/src/lib/payments.tsx
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/page-client.tsx
apps/dashboard/src/components/data-table/payment-item-table.tsx
apps/e2e/tests/backend/backend-helpers.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
packages/stack-shared/src/interface/admin-interface.ts
packages/stack-shared/src/interface/server-interface.ts
packages/stack-shared/src/known-errors.tsx
packages/stack-shared/src/schema-fields.ts
packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts
packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts
packages/template/src/lib/stack-app/apps/interfaces/client-app.ts
packages/template/src/lib/stack-app/apps/interfaces/server-app.ts

Need help? Join our Discord

@patched-codes
Copy link

patched-codes bot commented Aug 20, 2025

Documentation Changes Required

Based on the analysis provided, the following changes are required in the documentation:

1. ./docs/templates/sdk/objects/stack-app.mdx

StackClientApp Section

  1. Update the Table of Contents to include the new item property.
  2. Add a new CollapsibleMethodSection to document the usage of the item property.
  3. Include examples demonstrating how to retrieve an item using different identifier options (userId, teamId, or customId).
  4. Explain that the AsyncStoreProperty allows developers to access Item objects containing displayName, quantity, and nonNegativeQuantity properties.

StackServerApp Section

  1. Update the Table of Contents around line 530 to include:
    getItem({itemId, userId/teamId/customId}): Promise<ServerItem>; //$stack-link-to:#stackserverappgetitem
    // NEXT_LINE_PLATFORM react-like
    ⤷ useItem({itemId, userId/teamId/customId}): ServerItem; //$stack-link-to:#stackserverappuseitem
    
  2. Add a new section after the Team Management section explaining:
    • How to retrieve items by ID using different identifiers (userId, teamId, or customId)
    • Methods available on ServerItem (increaseQuantity, decreaseQuantity, tryDecreaseQuantity)
    • Include examples of retrieving and manipulating items

2. Other Documentation

  • The createItemQuantityChange method in the StackAdminApp interface has been modified to accept different types of identifiers (userId, teamId, or customId) instead of just customerId. However, there is currently no existing documentation for this method.
  • The createPurchaseUrl method has been removed, but there is no existing documentation to update.

Please ensure these changes are reflected in the relevant documentation files. If there are no existing documents for the StackAdminApp interface and its methods, consider creating new documentation to cover these features.

Copy link
Contributor

@coderabbitai coderabbitai bot left a 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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (4)
apps/dashboard/src/components/data-table/payment-item-table.tsx (1)

112-118: Bug: Custom customer IDs are blocked by UUID validation.

For customerType "custom", IDs can be arbitrary strings. The current uuid() requirement prevents valid custom flows.

Apply this diff to validate conditionally by customerType:

   const schema = yup.object({
-    customerType: yup.string().oneOf(["user", "team", "custom"]).defined().label("Customer Type"),
-    customerId: yup.string().uuid().defined().label("Customer ID"),
+    customerType: yup.string().oneOf(["user", "team", "custom"]).defined().label("Customer Type"),
+    customerId: yup.string().when("customerType", {
+      is: "custom",
+      then: (s) => s.trim().min(1),
+      otherwise: (s) => s.uuid(),
+    }).defined().label("Customer ID"),
     quantity: yup.number().defined().label("Quantity"),
     description: yup.string().optional().label("Description"),
     expiresAt: yup.date().optional().label("Expires At"),
   });
apps/e2e/tests/backend/endpoints/api/v1/payments/create-purchase-url.test.ts (1)

108-112: Potentially brittle: mismatch between offer.customerType and provided customer_type

This test sets customer_type: "team" but the configured offer has customerType: "user". If the backend validates customer_type vs offer before checking customer existence (likely per PR), this may yield an OfferCustomerTypeDoesNotMatch error instead of CUSTOMER_DOES_NOT_EXIST and cause test flakiness.

Apply one of the following to align intent (“invalid customer_id”):

Option A (recommended): keep the offer as user-type and change the body’s customer_type to "user":

-      customer_type: "team",
+      customer_type: "user",

Option B: if you want to test an invalid team ID, also change the configured offer to team to avoid offer/type mismatch. For example (not a diff since outside this hunk):

// where the offer is configured above in this test
customerType: "team",
apps/backend/src/app/api/latest/payments/purchases/create-purchase-url/route.ts (1)

44-47: Avoid cross-type Stripe customer collisions and map CUSTOM correctly.

Two issues:

  • The search only matches on customerId, so the same ID string across types (user/team/custom) can collide. Include customerType in the search query.
  • custom currently maps to TEAM in Stripe metadata. Persist CUSTOM to keep parity with your DB enum and prevent downstream mismatches.
-    const stripeCustomerSearch = await stripe.customers.search({
-      query: `metadata['customerId']:'${req.body.customer_id}'`,
-    });
+    const escapedId = req.body.customer_id.replace(/'/g, "\\'");
+    const stripeCustomerType =
+      customerType === "user"
+        ? CustomerType.USER
+        : customerType === "team"
+          ? CustomerType.TEAM
+          : CustomerType.CUSTOM;
+    const stripeCustomerSearch = await stripe.customers.search({
+      // Match both ID and type to avoid collisions across customer scopes
+      query: `metadata['customerId']:'${escapedId}' AND metadata['customerType']:'${stripeCustomerType}'`,
+    });

     let stripeCustomer = stripeCustomerSearch.data.length ? stripeCustomerSearch.data[0] : undefined;
     if (!stripeCustomer) {
       stripeCustomer = await stripe.customers.create({
         metadata: {
           customerId: req.body.customer_id,
-          customerType: customerType === "user" ? CustomerType.USER : CustomerType.TEAM,
+          customerType: stripeCustomerType,
         }
       });
     }

Also applies to: 49-55

apps/backend/src/app/api/latest/payments/items/[customer_type]/[customer_id]/[item_id]/update-quantity/route.ts (1)

25-26: Missing allow_negative in internal updateItemQuantity calls

The new route schema requires the allow_negative query parameter, but several internal calls to updateItemQuantity omit it—these will now fail validation. Please update each call to include allow_negative set appropriately (e.g. false for increments, true for decrements):

• packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts:516
await this._interface.updateItemQuantity(…, { delta: options.quantity /* add allow_negative */ })

• packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts:733
await app._interface.updateItemQuantity(updateOptions, { delta /* add allow_negative */ })

• packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts:756
await app._interface.updateItemQuantity(updateOptions, { delta: -delta /* add allow_negative */ })

Adjust these to, for example:

{ delta: options.quantity, allow_negative: false }
{ delta,                 allow_negative: false }
{ delta: -delta,         allow_negative: true  }
♻️ Duplicate comments (4)
apps/backend/prisma/migrations/20250820164831_custom_customer_types/migration.sql (1)

1-16: Adding NOT NULL column without default can fail on non-empty tables.

This will error if ItemQuantityChange has rows. Use a two-step migration: add as nullable, backfill, then set NOT NULL. Also ensure backfill uses the correct customerType derived from your data.

Here’s a safe pattern to adapt:

 -- AlterEnum
 ALTER TYPE "CustomerType" ADD VALUE 'CUSTOM';

--- AlterTable
-ALTER TABLE "ItemQuantityChange" ADD COLUMN     "customerType" "CustomerType" NOT NULL,
-ALTER COLUMN "customerId" SET DATA TYPE TEXT;
+-- AlterTable (step 1: add nullable)
+ALTER TABLE "ItemQuantityChange" 
+  ADD COLUMN "customerType" "CustomerType",
+  ALTER COLUMN "customerId" SET DATA TYPE TEXT;
+
+-- Backfill (step 2: set proper values; adjust logic to your data model)
+-- Example placeholder (set all to USER if that's guaranteed safe; otherwise join against items config)
+-- UPDATE "ItemQuantityChange" ic SET "customerType" = 'USER' WHERE "customerType" IS NULL;
+
+-- AlterTable (step 3: enforce NOT NULL)
+ALTER TABLE "ItemQuantityChange" 
+  ALTER COLUMN "customerType" SET NOT NULL;

 -- AlterTable
 ALTER TABLE "Subscription" ALTER COLUMN "customerId" SET DATA TYPE TEXT;

If the table is guaranteed empty in all environments, keeping NOT NULL in a single step is fine; otherwise, prefer the two-step approach.

packages/stack-shared/src/interface/client-interface.ts (1)

1779-1801: Use stringifyJson per code patterns (JSON.stringify is disallowed here)

This file adheres to a rule to use stringifyJson from stack-shared/utils/json. The changed line still uses JSON.stringify.

Apply within the changed range:

-        body: JSON.stringify({ customer_type, customer_id, ...offerBody }),
+        body: stringifyJson({ customer_type, customer_id, ...offerBody }),

And add the import (outside the changed hunk):

// at the top alongside ReadonlyJson
import { ReadonlyJson, stringifyJson } from '../utils/json';
packages/stack-shared/src/interface/server-interface.ts (1)

862-867: Encode path segments (customerType/customerId/itemId) to avoid malformed URLs and potential routing issues.

The template literal inserts unencoded values into the path. If any identifier contains reserved characters (e.g., spaces, slashes, unicode), requests can break or misroute. Encode each segment or use the project’s urlString util consistently.

-      `/payments/items/${customerType}/${customerId}/${itemId}/update-quantity?${queryParams.toString()}`,
+      `/payments/items/${encodeURIComponent(customerType)}/${encodeURIComponent(customerId)}/${encodeURIComponent(itemId)}/update-quantity?${queryParams.toString()}`,

Alternatively (for consistency with the rest of this file), you can switch to urlString for the path and append the query string:

const path = urlString`/payments/items/${customerType}/${customerId}/${itemId}/update-quantity` + `?${queryParams.toString()}`;
await this.sendServerRequest(path, { /* ... */ }, null);
packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts (1)

511-518: Remove any-cast; narrow the discriminated union safely.

The current approach uses (options as any).customId. Prefer standard discriminated narrowing to retain type safety.

-  async createItemQuantityChange(options: (
-    { userId: string, itemId: string, quantity: number, expiresAt?: string, description?: string } |
-    { teamId: string, itemId: string, quantity: number, expiresAt?: string, description?: string } |
-    { customId: string, itemId: string, quantity: number, expiresAt?: string, description?: string }
-  )): Promise<void> {
+  async createItemQuantityChange(options: (
+    { userId: string, itemId: string, quantity: number, expiresAt?: string, description?: string } |
+    { teamId: string, itemId: string, quantity: number, expiresAt?: string, description?: string } |
+    { customId: string, itemId: string, quantity: number, expiresAt?: string, description?: string }
+  )): Promise<void> {
     await this._interface.updateItemQuantity(
-      { itemId: options.itemId, ...("userId" in options ? { userId: options.userId } : ("teamId" in options ? { teamId: options.teamId } : { customId: (options as any).customId })) },
+      {
+        itemId: options.itemId,
+        ...("userId" in options
+          ? { userId: options.userId }
+          : ("teamId" in options
+            ? { teamId: options.teamId }
+            : { customId: options.customId }))
+      },
       {
         delta: options.quantity,
         expires_at: options.expiresAt,
         description: options.description,
       }
     );
   }
🧹 Nitpick comments (17)
apps/backend/prisma/schema.prisma (2)

22-22: Confirm unrelated change to ownerTeamId.

Line 22 is marked as modified, but it’s effectively the same declaration. If this was accidental churn (e.g., whitespace/formatting), consider dropping it to reduce noise. If intentional, ignore.


753-765: Consider indexing customerType for query efficiency.

Likely hot paths filter by tenancyId + customerType + customerId + itemId (+ expiresAt). Current index omits customerType and itemId, which can degrade performance with the new dimension.

Apply this diff to add a composite index:

 model ItemQuantityChange {
   id           String       @default(uuid()) @db.Uuid
   tenancyId    String       @db.Uuid
   customerType CustomerType
   customerId   String
   itemId       String
   quantity     Int
   description  String?
   expiresAt    DateTime?
   createdAt    DateTime     @default(now())

   @@id([tenancyId, id])
-  @@index([tenancyId, customerId, expiresAt])
+  @@index([tenancyId, customerId, expiresAt])
+  @@index([tenancyId, customerType, customerId, itemId, expiresAt])
 }
apps/backend/src/lib/payments.tsx (1)

58-101: Customer-type aware quantity calculation looks correct; add DB indexes to keep queries fast

The switch to filtering by customerType (uppercased) in both Subscription and ItemQuantityChange is correct and consistent with the new enum. To prevent regressions at scale, add composite indexes aligned with these where-clauses.

Suggested indexes (pseudo-sql/Prisma):

  • Subscription: (tenancyId, customerType, customerId, status)
  • ItemQuantityChange: (tenancyId, customerType, customerId, itemId, expiresAt)

These match the high-selectivity filters used by getItemQuantityForCustomer.

apps/e2e/tests/backend/endpoints/api/v1/payments/purchase-session.test.ts (1)

94-113: Optionally add coverage for mismatched customer_type vs inline_offer.customer_type

A negative test asserting OfferCustomerTypeDoesNotMatch would harden the contract.

If helpful, I can draft an additional test case that sends a mismatched pair and expects the known error response.

packages/template/src/lib/stack-app/apps/interfaces/server-app.ts (1)

58-63: Deduplicate the “item key” union across client/server

The exact union type for item keys is duplicated here and in the client interface. Consider extracting a shared alias (e.g., ItemKey = { itemId: string } & ({ userId: string } | { teamId: string } | { customId: string })) in customers/index.ts and importing it in both places to avoid drift.

Example:

// packages/template/src/lib/stack-app/customers/index.ts
export type ItemKey =
  | { itemId: string, userId: string }
  | { itemId: string, teamId: string }
  | { itemId: string, customId: string };

Then here:

-  & AsyncStoreProperty<
-    "item",
-    [{ itemId: string, userId: string } | { itemId: string, teamId: string } | { itemId: string, customId: string }],
-    ServerItem,
-    false
-  >
+  & AsyncStoreProperty<"item", [ItemKey], ServerItem, false>
apps/e2e/tests/backend/endpoints/api/v1/payments/create-purchase-url.test.ts (2)

232-238: Avoid snapshotting dynamic purchase URL — reduce flakiness

Inline snapshot asserts the full URL including a generated token. Even with "", the remaining random suffix may change. Prefer asserting status + URL shape (you already do with the regex below) and skip the full inline snapshot.

Replace the snapshot with a direct status assertion:

-  expect(response).toMatchInlineSnapshot(`
-    NiceResponse {
-      "status": 200,
-      "body": { "url": "http://localhost:8101/purchase/<stripped UUID>_cngg259jnn72d55dxfzmafzan54vcw7n429evq7bfbaa0" },
-      "headers": Headers { <some fields may have been hidden> },
-    }
-  `);
+  expect(response.status).toBe(200);

171-201: Nit: duplicate customer_type in nested offer_inline

You’re specifying customer_type at both the top-level request and inside offer_inline. If the route requires both, ignore this. Otherwise consider relying on a single source of truth to avoid divergence in future edits.

If only one is required, consider removing the redundant one in the client-path negative test to simplify payload.

Also applies to: 211-231

apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/page-client.tsx (1)

171-171: DRY up duplicated customer type literals

The allowed values and select options are repeated for offers and items. Consider centralizing:

  • A shared union literal array (e.g., const customerTypeOptions = [{ value: "user", ...}, ...]) and reuse in both forms.
  • A shared yup schema (if not already in stack-shared) imported here to avoid drift.

Example:

const customerTypeOptions = [
  { value: "user", label: "User" },
  { value: "team", label: "Team" },
  { value: "custom", label: "Custom" },
];

// In both schemas:
yup.string().oneOf(customerTypeOptions.map(o => o.value as const)).defined().label("Customer Type");

Also applies to: 233-239

packages/template/src/lib/stack-app/apps/interfaces/client-app.ts (1)

80-85: Avoid duplicating the item key union across client/server

Same suggestion as server interface: extract a shared ItemKey type to customers/index.ts and reuse here for consistency and easier evolution.

-  & AsyncStoreProperty<
-    "item",
-    [{ itemId: string, userId: string } | { itemId: string, teamId: string } | { itemId: string, customId: string }],
-    Item,
-    false
-  >
+  & AsyncStoreProperty<"item", [ItemKey], Item, false>
apps/e2e/tests/backend/endpoints/api/v1/payments/items.test.ts (1)

165-172: Reduce URL construction duplication with small helpers

The file repeats building item paths and querystrings. Consider tiny helpers to reduce duplication and make future route-shape changes safer.

Example at top of file:

function itemUrl(scope: "user" | "team" | "custom", id: string, itemId: string) {
  return `/api/latest/payments/items/${scope}/${id}/${itemId}`;
}
function updateQuantityUrl(scope: "user" | "team" | "custom", id: string, itemId: string, allowNegative: boolean) {
  return `${itemUrl(scope, id, itemId)}/update-quantity?allow_negative=${allowNegative ? "true" : "false"}`;
}

Then:

await niceBackendFetch(updateQuantityUrl("user", user.userId, "test-item", false), { ... })

Also applies to: 194-199, 224-229, 255-261, 424-428

packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts (1)

79-83: Type union looks good; consider DRYing shared fields for maintainability.

Options share the same item fields; extracting a base type reduces duplication and helps future changes (e.g., adding a new optional field once).

Apply this minimal change inside the existing union (optional), or introduce shared types at file scope:

-    createItemQuantityChange(options: (
-      { userId: string, itemId: string, quantity: number, expiresAt?: string, description?: string } |
-      { teamId: string, itemId: string, quantity: number, expiresAt?: string, description?: string } |
-      { customId: string, itemId: string, quantity: number, expiresAt?: string, description?: string }
-    )): Promise<void>,
+    createItemQuantityChange(options: (
+      ({ userId: string } | { teamId: string } | { customId: string })
+      & { itemId: string, quantity: number, expiresAt?: string, description?: string }
+    )): Promise<void>,

If you prefer explicit names, add these near the top of the file and then reference them:

type ItemQuantityChangeCore = { itemId: string; quantity: number; expiresAt?: string; description?: string };
type ItemOwner = { userId: string } | { teamId: string } | { customId: string };
type CreateItemQuantityChangeOptions = ItemOwner & ItemQuantityChangeCore;
// ...
// createItemQuantityChange(options: CreateItemQuantityChangeOptions): Promise<void>
packages/stack-shared/src/interface/server-interface.ts (1)

845-846: Remove redundant type annotation; rely on inference.

Minor cleanup: the explicit type on itemId is redundant.

-    const itemId: string = options.itemId;
+    const { itemId } = options;
packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts (1)

1206-1217: Deduplicate getItem branch logic.

This can be simplified while keeping behavior intact.

-  async getItem(options: { itemId: string, userId: string } | { itemId: string, teamId: string } | { itemId: string, customId: string }): Promise<Item> {
-    const session = await this._getSession();
-    let crud: ItemCrud['Client']['Read'];
-    if ("userId" in options) {
-      crud = Result.orThrow(await this._userItemCache.getOrWait([session, options.userId, options.itemId], "write-only"));
-    } else if ("teamId" in options) {
-      crud = Result.orThrow(await this._teamItemCache.getOrWait([session, options.teamId, options.itemId], "write-only"));
-    } else {
-      crud = Result.orThrow(await this._customItemCache.getOrWait([session, options.customId, options.itemId], "write-only"));
-    }
-    return this._clientItemFromCrud(crud);
-  }
+  async getItem(options: { itemId: string, userId: string } | { itemId: string, teamId: string } | { itemId: string, customId: string }): Promise<Item> {
+    const session = await this._getSession();
+    const [cache, ownerId] =
+      "userId" in options ? [this._userItemCache, options.userId] :
+      "teamId" in options ? [this._teamItemCache, options.teamId] :
+                            [this._customItemCache, options.customId];
+    const crud = Result.orThrow(await cache.getOrWait([session, ownerId, options.itemId] as const, "write-only"));
+    return this._clientItemFromCrud(crud);
+  }
apps/e2e/tests/js/payments.test.ts (1)

192-192: Strengthen assertion: expect the specific KnownError.

Asserting the precise error type improves signal and avoids false positives.

-  await expect(adminApp.createItemQuantityChange({ teamId: team.id, itemId, quantity: -1 }))
-    .rejects.toThrow();
+  await expect(
+    adminApp.createItemQuantityChange({ teamId: team.id, itemId, quantity: -1 })
+  ).rejects.toThrow(KnownErrors.ItemQuantityInsufficientAmount);

Add this import at the top of the file:

import { KnownErrors } from "@stackframe/stack-shared";
apps/backend/src/app/api/latest/payments/items/[customer_type]/[customer_id]/[item_id]/route.ts (1)

20-23: Prefer shared schema: use customerTypeSchema for a single source of truth.

Inline oneOf duplicates definitions across the codebase. Importing the shared schema keeps enum values consistent with future changes.

-      customer_type: yupString().oneOf(["user", "team", "custom"]).defined(),
+      customer_type: customerTypeSchema.defined(),

Add to the existing schema-fields import:

import { adaptSchema, clientOrHigherAuthTypeSchema, yupNumber, yupObject, yupString, customerTypeSchema } from "@stackframe/stack-shared/dist/schema-fields";
apps/backend/src/app/api/latest/payments/purchases/create-purchase-url/route.ts (1)

22-24: Add light schema validation for customer_id

Although we escape single quotes when constructing the Stripe query, constraining customer_id at the schema level helps guard against malformed or malicious inputs and avoids query errors.

• File: apps/backend/src/app/api/latest/payments/purchases/create-purchase-url/route.ts
Lines: 22–24

Suggested diff:

   customer_type: yupString().oneOf(["user", "team", "custom"]).defined(),
-  customer_id: yupString().defined(),
+  customer_id: yupString()
+    .matches(/^[A-Za-z0-9_-]+$/, "customer_id may only contain letters, numbers, hyphens, and underscores")
+    .defined(),
   offer_id: yupString().optional(),
apps/backend/src/app/api/latest/payments/items/[customer_type]/[customer_id]/[item_id]/update-quantity/route.ts (1)

69-74: Validate expires_at as an ISO date to avoid Invalid Date writes.

new Date(req.body.expires_at) will produce Invalid Date for malformed input, which can bubble into runtime/DB errors. Prefer explicit date validation.

-      expires_at: yupString().optional(),
+      // If ISO 8601 is expected, enforce it at the edge
+      expires_at: yupString().matches(
+        /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/,
+        "expires_at must be an ISO 8601 UTC timestamp"
+      ).optional(),

Alternatively, use a yup date schema if available and acceptable across the codebase.

📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between b0e7706 and 5e5c0ae.

📒 Files selected for processing (25)
  • apps/backend/prisma/migrations/20250820164831_custom_customer_types/migration.sql (1 hunks)
  • apps/backend/prisma/schema.prisma (4 hunks)
  • apps/backend/src/app/api/latest/payments/items/[customer_type]/[customer_id]/[item_id]/route.ts (4 hunks)
  • apps/backend/src/app/api/latest/payments/items/[customer_type]/[customer_id]/[item_id]/update-quantity/route.ts (6 hunks)
  • apps/backend/src/app/api/latest/payments/purchases/create-purchase-url/route.ts (3 hunks)
  • apps/backend/src/app/api/latest/team-invitations/accept/verification-code-handler.tsx (1 hunks)
  • apps/backend/src/lib/payments.tsx (3 hunks)
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/page-client.tsx (4 hunks)
  • apps/dashboard/src/components/data-table/payment-item-table.tsx (2 hunks)
  • apps/e2e/tests/backend/backend-helpers.ts (1 hunks)
  • apps/e2e/tests/backend/endpoints/api/v1/payments/create-purchase-url.test.ts (8 hunks)
  • apps/e2e/tests/backend/endpoints/api/v1/payments/items.test.ts (14 hunks)
  • apps/e2e/tests/backend/endpoints/api/v1/payments/purchase-session.test.ts (1 hunks)
  • apps/e2e/tests/js/payments.test.ts (3 hunks)
  • packages/stack-shared/src/interface/admin-interface.ts (0 hunks)
  • packages/stack-shared/src/interface/client-interface.ts (3 hunks)
  • packages/stack-shared/src/interface/server-interface.ts (1 hunks)
  • packages/stack-shared/src/known-errors.tsx (2 hunks)
  • packages/stack-shared/src/schema-fields.ts (1 hunks)
  • packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts (1 hunks)
  • packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts (4 hunks)
  • packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts (6 hunks)
  • packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts (1 hunks)
  • packages/template/src/lib/stack-app/apps/interfaces/client-app.ts (2 hunks)
  • packages/template/src/lib/stack-app/apps/interfaces/server-app.ts (2 hunks)
💤 Files with no reviewable changes (1)
  • packages/stack-shared/src/interface/admin-interface.ts
🧰 Additional context used
📓 Path-based instructions (2)
apps/backend/src/app/api/latest/**/*

📄 CodeRabbit Inference Engine (CLAUDE.md)

apps/backend/src/app/api/latest/**/*: Main API routes are located in /apps/backend/src/app/api/latest
The project uses a custom route handler system in the backend for consistent API responses

Files:

  • apps/backend/src/app/api/latest/team-invitations/accept/verification-code-handler.tsx
  • apps/backend/src/app/api/latest/payments/items/[customer_type]/[customer_id]/[item_id]/route.ts
  • apps/backend/src/app/api/latest/payments/purchases/create-purchase-url/route.ts
  • apps/backend/src/app/api/latest/payments/items/[customer_type]/[customer_id]/[item_id]/update-quantity/route.ts
apps/backend/prisma/schema.prisma

📄 CodeRabbit Inference Engine (CLAUDE.md)

Database models use Prisma

Files:

  • apps/backend/prisma/schema.prisma
🧠 Learnings (1)
📚 Learning: 2025-08-04T22:25:51.260Z
Learnt from: CR
PR: stack-auth/stack-auth#0
File: CLAUDE.md:0-0
Timestamp: 2025-08-04T22:25:51.260Z
Learning: Applies to apps/backend/prisma/schema.prisma : Database models use Prisma

Applied to files:

  • apps/backend/prisma/schema.prisma
🧬 Code Graph Analysis (13)
packages/template/src/lib/stack-app/apps/interfaces/server-app.ts (2)
packages/template/src/lib/stack-app/common.ts (1)
  • AsyncStoreProperty (8-10)
packages/template/src/lib/stack-app/customers/index.ts (1)
  • ServerItem (19-33)
apps/e2e/tests/backend/endpoints/api/v1/payments/items.test.ts (3)
apps/e2e/tests/backend/backend-helpers.ts (2)
  • niceBackendFetch (107-165)
  • updateConfig (1117-1125)
docs/public/stack-auth-cli-template.py (1)
  • post (20-31)
packages/stack-shared/src/interface/admin-interface.ts (1)
  • updateConfig (441-454)
apps/e2e/tests/js/payments.test.ts (2)
apps/e2e/tests/helpers.ts (1)
  • it (10-10)
apps/e2e/tests/js/js-helpers.ts (1)
  • createApp (40-77)
packages/template/src/lib/stack-app/apps/interfaces/client-app.ts (2)
packages/template/src/lib/stack-app/common.ts (1)
  • AsyncStoreProperty (8-10)
packages/template/src/lib/stack-app/customers/index.ts (1)
  • Item (7-17)
packages/stack-shared/src/interface/server-interface.ts (2)
packages/stack-shared/src/interface/crud/items.ts (1)
  • ItemCrud (24-24)
packages/stack-shared/src/utils/errors.tsx (1)
  • StackAssertionError (69-85)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/page-client.tsx (1)
apps/dashboard/src/components/form-fields.tsx (1)
  • SelectField (229-266)
apps/backend/src/app/api/latest/payments/items/[customer_type]/[customer_id]/[item_id]/route.ts (3)
packages/stack-shared/src/known-errors.tsx (2)
  • KnownErrors (1505-1507)
  • KnownErrors (1509-1625)
apps/backend/src/prisma-client.tsx (1)
  • getPrismaClientForTenancy (51-53)
apps/backend/src/lib/payments.tsx (1)
  • getItemQuantityForCustomer (58-100)
apps/backend/src/app/api/latest/payments/purchases/create-purchase-url/route.ts (1)
packages/stack-shared/src/known-errors.tsx (2)
  • KnownErrors (1505-1507)
  • KnownErrors (1509-1625)
apps/backend/src/lib/payments.tsx (2)
packages/stack-shared/src/utils/objects.tsx (1)
  • getOrUndefined (543-545)
packages/stack-shared/src/utils/strings.tsx (1)
  • typedToUppercase (30-33)
packages/stack-shared/src/interface/client-interface.ts (1)
packages/stack-shared/src/utils/errors.tsx (1)
  • StackAssertionError (69-85)
apps/backend/src/app/api/latest/payments/items/[customer_type]/[customer_id]/[item_id]/update-quantity/route.ts (3)
packages/stack-shared/src/schema-fields.ts (1)
  • yupString (187-190)
packages/stack-shared/src/known-errors.tsx (2)
  • KnownErrors (1505-1507)
  • KnownErrors (1509-1625)
packages/stack-shared/src/utils/strings.tsx (1)
  • typedToUppercase (30-33)
packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts (3)
packages/template/src/lib/stack-app/apps/implementations/common.ts (2)
  • createCacheBySession (29-39)
  • useAsyncCache (145-190)
packages/stack-shared/src/interface/crud/items.ts (1)
  • ItemCrud (24-24)
packages/template/src/lib/stack-app/customers/index.ts (1)
  • Item (7-17)
packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts (3)
packages/template/src/lib/stack-app/apps/implementations/common.ts (2)
  • createCache (22-27)
  • useAsyncCache (145-190)
packages/stack-shared/src/interface/crud/items.ts (1)
  • ItemCrud (24-24)
packages/template/src/lib/stack-app/customers/index.ts (1)
  • ServerItem (19-33)
🪛 Biome (2.1.2)
packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts

[error] 1224-1224: This hook is being called conditionally, but all hooks must be called in the exact same order in every component render.

For React to preserve state between calls, hooks needs to be called unconditionally and always in the same order.
See https://reactjs.org/docs/hooks-rules.html#only-call-hooks-at-the-top-level

(lint/correctness/useHookAtTopLevel)


[error] 1226-1226: This hook is being called conditionally, but all hooks must be called in the exact same order in every component render.

For React to preserve state between calls, hooks needs to be called unconditionally and always in the same order.
See https://reactjs.org/docs/hooks-rules.html#only-call-hooks-at-the-top-level

(lint/correctness/useHookAtTopLevel)


[error] 1228-1228: This hook is being called conditionally, but all hooks must be called in the exact same order in every component render.

For React to preserve state between calls, hooks needs to be called unconditionally and always in the same order.
See https://reactjs.org/docs/hooks-rules.html#only-call-hooks-at-the-top-level

(lint/correctness/useHookAtTopLevel)

packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts

[error] 1013-1013: This hook is being called conditionally, but all hooks must be called in the exact same order in every component render.

For React to preserve state between calls, hooks needs to be called unconditionally and always in the same order.
See https://reactjs.org/docs/hooks-rules.html#only-call-hooks-at-the-top-level

(lint/correctness/useHookAtTopLevel)


[error] 1014-1014: This hook is being called conditionally, but all hooks must be called in the exact same order in every component render.

For React to preserve state between calls, hooks needs to be called unconditionally and always in the same order.
See https://reactjs.org/docs/hooks-rules.html#only-call-hooks-at-the-top-level

(lint/correctness/useHookAtTopLevel)


[error] 1016-1016: This hook is being called conditionally, but all hooks must be called in the exact same order in every component render.

For React to preserve state between calls, hooks needs to be called unconditionally and always in the same order.
See https://reactjs.org/docs/hooks-rules.html#only-call-hooks-at-the-top-level

(lint/correctness/useHookAtTopLevel)


[error] 1017-1017: This hook is being called conditionally, but all hooks must be called in the exact same order in every component render.

For React to preserve state between calls, hooks needs to be called unconditionally and always in the same order.
See https://reactjs.org/docs/hooks-rules.html#only-call-hooks-at-the-top-level

(lint/correctness/useHookAtTopLevel)


[error] 1019-1019: This hook is being called conditionally, but all hooks must be called in the exact same order in every component render.

For React to preserve state between calls, hooks needs to be called unconditionally and always in the same order.
See https://reactjs.org/docs/hooks-rules.html#only-call-hooks-at-the-top-level

(lint/correctness/useHookAtTopLevel)


[error] 1020-1020: This hook is being called conditionally, but all hooks must be called in the exact same order in every component render.

For React to preserve state between calls, hooks needs to be called unconditionally and always in the same order.
See https://reactjs.org/docs/hooks-rules.html#only-call-hooks-at-the-top-level

(lint/correctness/useHookAtTopLevel)

⏰ 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). (8)
  • GitHub Check: lint_and_build (latest)
  • GitHub Check: build (22.x)
  • GitHub Check: setup-tests
  • GitHub Check: restart-dev-and-test
  • GitHub Check: build (22.x)
  • GitHub Check: all-good
  • GitHub Check: docker
  • GitHub Check: Security Check
🔇 Additional comments (27)
packages/stack-shared/src/schema-fields.ts (1)

547-547: LGTM: Added "custom" to customerTypeSchema is consistent with the PR direction.

Matches downstream DB enum and route handling. No further changes needed here.

apps/backend/prisma/schema.prisma (2)

718-719: LGTM: CustomerType enum extended to include CUSTOM.

This aligns with the shared schema and API updates.


735-737: No residual UUID constraints found on Subscription.customerId

Automated searches across all schema and code files show:

  • No @db.Uuid usage on any customerId in your *.prisma schemas
  • No .uuid() validations on customerId, customer_id, or customId in TS/JS code

The change to TEXT for Subscription.customerId appears safe.

apps/dashboard/src/components/data-table/payment-item-table.tsx (1)

122-128: LGTM: Payload mapping correctly emits userId/teamId/customId based on customerType.

This aligns with the updated server interface.

packages/stack-shared/src/known-errors.tsx (2)

1433-1444: LGTM: ItemCustomerTypeDoesNotMatch now supports "custom".

Type unions and message payloads are correctly extended.


1476-1487: LGTM: OfferCustomerTypeDoesNotMatch now supports "custom".

Consistent with schema and API updates.

apps/backend/src/app/api/latest/team-invitations/accept/verification-code-handler.tsx (1)

85-92: LGTM: Passing customerType="team" to getItemQuantityForCustomer

Correctly updated to the new signature; aligns with the team-admins quota check.

apps/e2e/tests/backend/endpoints/api/v1/payments/purchase-session.test.ts (1)

94-113: LGTM: Test payload includes customer_type for inline offer path

Matches the updated API contract and route validation.

apps/e2e/tests/backend/backend-helpers.ts (1)

1417-1426: LGTM: Helper updated to send customer_type in create-purchase-url

Keeps helper aligned with the new API shape and avoids duplication across tests.

packages/template/src/lib/stack-app/apps/interfaces/server-app.ts (1)

58-63: Runtime parity confirmed for tri-scoped item access

The server implementation _StackServerAppImpl’s methods cover all three key shapes exactly as the store property’s type:

  • async getItem(options: { itemId, userId } | { itemId, teamId } | { itemId, customId }): Promise<ServerItem> (lines 995–1008)
  • useItem(options: { itemId, userId } | { itemId, teamId } | { itemId, customId }): ServerItem (lines 1011–1022)

All cases—including customId—are handled. Approving these changes.

apps/e2e/tests/backend/endpoints/api/v1/payments/create-purchase-url.test.ts (1)

16-18: customer_type added to payloads — consistent with new API contract

Adding customer_type to the request body in all relevant tests is correct and matches the backend expectations. The snake_case matches the e2e helpers’ validations.

Also applies to: 56-59, 156-159, 184-201, 216-231

apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/page-client.tsx (1)

171-171: UI accepts “custom” customer type — schema and selects updated correctly

  • offerSchema customerType oneOf includes "custom"
  • itemSchema customerType oneOf includes "custom"
  • Both SelectField renderers expose the Custom option
    Looks consistent with backend and shared schema changes.

Also applies to: 213-214, 233-239

packages/template/src/lib/stack-app/apps/interfaces/client-app.ts (1)

5-5: Client-side item store property mirrors server-side — verified

Confirmed: the client implementation exposes getItem/useItem and correctly handles userId, teamId and customId; the Item type is imported on the interface.

  • packages/template/src/lib/stack-app/apps/interfaces/client-app.ts — import Item added (line ~5)
  • packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts — caches: _userItemCache/_teamItemCache/_customItemCache (lines ~203–219); getItem implementation (lines ~1206–1216) uses _customItemCache for customId; useItem implementation (lines ~1220–1230) handles customId branch.
  • packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts — server-side parity present (caches and getItem/useItem handle customId; e.g. caches ~159–175, getItem/useItem ~997–1022).
apps/e2e/tests/backend/endpoints/api/v1/payments/items.test.ts (2)

18-19: Endpoint paths updated to include customer_type segment — aligns with backend routes

All affected tests now use /items/{customer_type}/{customer_id}/{item_id}[...]. This is consistent and improves clarity across user/team/custom scopes. The admin/dashboard path update for team is also correct.

Also applies to: 56-59, 89-92, 125-128, 165-172, 194-199, 201-206, 224-229, 231-236, 255-261, 263-268, 285-289, 327-331, 359-365, 424-428, 439-453


455-489: Great addition: coverage for custom customer type (GET and update-quantity)

Good end-to-end verification of default quantity, update-quantity behavior, and subsequent aggregation for a custom customer.

packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts (2)

215-219: Good addition: _customItemCache aligns with “custom” scope.

Cache wiring mirrors user/team caches and uses the same interface call path. Looks consistent.


1201-1202: Verify createCheckoutUrl signature alignment end-to-end.

Now passing customer type as the first argument. Ensure the underlying interface and all call sites accept (type, ownerId, offer) to prevent runtime errors.

Would you like me to run a quick repo scan to locate all createCheckoutUrl definitions/usages and confirm the updated signature is applied everywhere?

apps/e2e/tests/js/payments.test.ts (3)

51-85: LGTM: Validates root-level getItem for team and user scopes.

Good coverage of both team and user contexts, including server-side retrieval for user items.


87-107: LGTM: Adds “custom” happy-path with admin quantity change.

Nice end-to-end assertion that admin-side changes propagate to client reads for customId.


147-147: LGTM: Updated admin quantity change to use teamId.

This matches the new admin API options and keeps the test intent intact.

apps/backend/src/app/api/latest/payments/items/[customer_type]/[customer_id]/[item_id]/route.ts (1)

42-44: Good: explicit customer_type vs. item-config mismatch check.

Early, clear error when the requested customer scope doesn’t match the item’s configured scope. Matches KnownErrors and keeps behavior predictable.

apps/backend/src/app/api/latest/payments/items/[customer_type]/[customer_id]/[item_id]/update-quantity/route.ts (2)

49-51: Inline type check is correct and aligned with KnownErrors.

Good replacement of the helper with a direct comparison and a precise KnownErrors path.


60-61: No action needed: customerType is uppercased internally

The getItemQuantityForCustomer function uses typedToUppercase(options.customerType) in both its subscription.findMany and itemQuantityChange.aggregate queries, so passing req.params.customer_type (lowercase) is correctly handled.

packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts (4)

171-175: Good addition: dedicated custom items cache.

This mirrors user/team caches and keeps cache key structure consistent.


577-579: Checkout URL calls updated to include owner type – aligned with new API.

Passing "user"/"team" explicitly is correct per the route changes.

Also applies to: 715-717


721-737: Item mutations correctly pass scoped identifiers and refresh the right caches.

The tri-scope updateOptions construction and cache refresh logic look good. The tryDecreaseQuantity path handles insufficient balance via KnownErrors.

Also applies to: 739-748, 750-767


997-1008: Tri-scope getItem implementation is solid.

Clear branching per discriminant with consistent cache usage; returns a unified ServerItem via _serverItemFromCrud.

Copy link
Contributor

@coderabbitai coderabbitai bot left a 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

🧹 Nitpick comments (6)
apps/e2e/tests/backend/endpoints/api/v1/payments/create-purchase-url.test.ts (1)

5-27: Consider adding at least one positive/negative case for customer_type: "custom".

Since this PR adds "custom" support across the stack, adding a basic acceptance test here (valid customId path + mismatch/error path) would tighten coverage for the purchases endpoint too.

I can draft two focused tests: one success with offer_id configured for "custom", and one mismatch returning OFFER_CUSTOMER_TYPE_DOES_NOT_MATCH. Want me to add them?

Also applies to: 29-78, 134-173, 176-206, 208-239, 241-278

apps/backend/src/lib/payments.tsx (1)

103-140: ensureCustomerExists: use UserIdDoesNotExist for users and guard “custom” IDs

For consistency and better DX:

  • UserNotFound’s constructor takes zero arguments, so don’t pass customerId to it. If you want the ID in the error, swap to
    throw new KnownErrors.UserIdDoesNotExist(options.customerId)
  • Add an explicit guard for empty IDs when customerType === "custom"

Changes:

• In apps/backend/src/lib/payments.tsx

@@ export async function ensureCustomerExists(options: {
   if (options.customerType === "user") {
     if (!isUuid(options.customerId)) {
-      throw new KnownErrors.UserNotFound();
+      throw new KnownErrors.UserIdDoesNotExist(options.customerId);
     }
     const user = await options.prisma.projectUser.findUnique({
@@
     if (!user) {
-      throw new KnownErrors.UserNotFound();
+      throw new KnownErrors.UserIdDoesNotExist(options.customerId);
     }
   } else if (options.customerType === "team") {
@@
-  }
+  } else if (options.customerType === "custom") {
+    if (options.customerId.trim().length === 0) {
+      // Keep error shape consistent with other 4xx validation paths
+      throw new StatusError(400, "customer_id must be non-empty for custom customers");
+    }
+  }

• Add import at top if not already present:

import { StatusError } from "@stackframe/stack-shared/dist/utils/errors";
apps/dashboard/src/components/data-table/payment-item-table.tsx (1)

147-151: Updated error handling matches new backend semantics.

Switching to UserNotFound/TeamNotFound is consistent with the new ensureCustomerExists flow. Consider adding a specific message for invalid/empty Custom IDs if you adopt a non-empty check server-side.

apps/backend/src/app/api/latest/payments/items/[customer_type]/[customer_id]/[item_id]/route.ts (1)

20-23: Optionally enforce non-empty custom customer_id.

Currently customer_id uses yupString().defined(), which allows empty strings. For "custom", that would flow through and produce changes for an empty identifier. Consider a minimal runtime check here (lighter than a schema-level conditional):

   if (!itemConfig) {
     throw new KnownErrors.ItemNotFound(req.params.item_id);
   }
+  if (req.params.customer_type === "custom" && req.params.customer_id.trim().length === 0) {
+    throw new StatusError(400, "customer_id must be non-empty for custom customers");
+  }

You’d need to import StatusError:

import { StatusError } from "@stackframe/stack-shared/dist/utils/errors";

If you’d prefer schema-level validation, we can add a conditional yup.when on customer_type.

Also applies to: 42-44

apps/backend/src/app/api/latest/payments/items/[customer_type]/[customer_id]/[item_id]/update-quantity/route.ts (2)

20-23: End-to-end: customer_type wiring and persistence look solid; validate expires_at to avoid invalid dates.

  • The route schema and mismatch check are correct.
  • ensureCustomerExists and uppercasing for persistence match the DB enum.
  • One potential footgun: expires_at is a free-form string. If it’s malformed, new Date(...) yields Invalid Date, which can lead to DB errors or bad data.

Strengthen the body schema or add a runtime guard:

Schema-level (minimal change using a test):

 body: yupObject({
   delta: yupNumber().integer().defined(),
-  expires_at: yupString().optional(),
+  expires_at: yupString()
+    .test("is-iso-date", "expires_at must be a valid ISO date string", v => !v || !Number.isNaN(Date.parse(v)))
+    .optional(),
   description: yupString().optional(),
 }).defined(),

Or runtime guard before create:

const expiresAt = req.body.expires_at ? new Date(req.body.expires_at) : null;
if (expiresAt && Number.isNaN(expiresAt.getTime())) {
  throw new StatusError(400, "expires_at must be a valid ISO date string");
}

(For this runtime approach, import StatusError from @stackframe/stack-shared/dist/utils/errors.)

Also applies to: 49-51, 53-58, 65-67, 75-80


25-26: Nit: consider a boolean query param for allow_negative.

Using "true"/"false" strings is fine, but switching to a boolean in the schema (and auto-parsing) would be a small DX improvement.

📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 5e5c0ae and 1018ee5.

📒 Files selected for processing (5)
  • apps/backend/src/app/api/latest/payments/items/[customer_type]/[customer_id]/[item_id]/route.ts (4 hunks)
  • apps/backend/src/app/api/latest/payments/items/[customer_type]/[customer_id]/[item_id]/update-quantity/route.ts (5 hunks)
  • apps/backend/src/lib/payments.tsx (4 hunks)
  • apps/dashboard/src/components/data-table/payment-item-table.tsx (5 hunks)
  • apps/e2e/tests/backend/endpoints/api/v1/payments/create-purchase-url.test.ts (8 hunks)
🧰 Additional context used
📓 Path-based instructions (1)
apps/backend/src/app/api/latest/**/*

📄 CodeRabbit Inference Engine (CLAUDE.md)

apps/backend/src/app/api/latest/**/*: Main API routes are located in /apps/backend/src/app/api/latest
The project uses a custom route handler system in the backend for consistent API responses

Files:

  • apps/backend/src/app/api/latest/payments/items/[customer_type]/[customer_id]/[item_id]/update-quantity/route.ts
  • apps/backend/src/app/api/latest/payments/items/[customer_type]/[customer_id]/[item_id]/route.ts
🧬 Code Graph Analysis (3)
apps/dashboard/src/components/data-table/payment-item-table.tsx (1)
packages/stack-shared/src/known-errors.tsx (2)
  • KnownErrors (1505-1507)
  • KnownErrors (1509-1625)
apps/e2e/tests/backend/endpoints/api/v1/payments/create-purchase-url.test.ts (1)
packages/stack-shared/src/utils/uuids.tsx (1)
  • generateUuid (3-8)
apps/backend/src/app/api/latest/payments/items/[customer_type]/[customer_id]/[item_id]/route.ts (3)
packages/stack-shared/src/schema-fields.ts (1)
  • yupString (187-190)
packages/stack-shared/src/known-errors.tsx (2)
  • KnownErrors (1505-1507)
  • KnownErrors (1509-1625)
apps/backend/src/prisma-client.tsx (1)
  • getPrismaClientForTenancy (51-53)
⏰ 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: build (22.x)
  • GitHub Check: setup-tests
  • GitHub Check: docker
  • GitHub Check: lint_and_build (latest)
  • GitHub Check: all-good
  • GitHub Check: build (22.x)
  • GitHub Check: docker
  • GitHub Check: restart-dev-and-test
  • GitHub Check: Security Check
🔇 Additional comments (7)
apps/e2e/tests/backend/endpoints/api/v1/payments/create-purchase-url.test.ts (2)

16-18: Good addition: thread customer_type through all test payloads.

This keeps the tests aligned with the new API contract requiring customer_type. The payload updates look consistent across scenarios.

Also applies to: 56-59, 161-164, 189-204, 221-235, 269-272


238-239: Assertion update LGTM.

Asserting on response.body.url with a regex is appropriate and keeps the snapshot compact.

apps/backend/src/lib/payments.tsx (1)

38-56: Inline offer normalization LGTM.

Mapping inline offers to the internal offer shape (currencies via SUPPORTED_CURRENCIES, includedItems normalization) is solid. This eliminates shape drift between inline and stored offers.

apps/dashboard/src/components/data-table/payment-item-table.tsx (1)

58-59: Prop threading to ActionsCell/CreateItemQuantityChangeDialog looks good.

Passing the full item to ActionsCell and threading itemId and customerType into the dialog aligns the UI with the backend customer typing.

Also applies to: 104-106

apps/backend/src/app/api/latest/payments/items/[customer_type]/[customer_id]/[item_id]/route.ts (3)

20-23: Schema: good addition of customer_type parameter.

oneOf(["user","team","custom"]) aligns with the expanded enum and keeps the public API explicit.


42-44: Explicit mismatch check is clear and correct.

Raising ItemCustomerTypeDoesNotMatch when the item’s configured type differs from the request param is the right behavior.


46-51: Customer existence check + quantity computation wiring LGTM.

  • ensureCustomerExists upfront prevents wasted work.
  • Passing customerType into getItemQuantityForCustomer ensures DB filters line up with the enum.

Also applies to: 52-58

Copy link
Contributor

@coderabbitai coderabbitai bot left a 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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts (1)

1308-1321: React hook useRouter is still called conditionally — violates hooks rules.

NextNavigation.useRouter must be invoked unconditionally inside this hook-like method.

Apply this refactor to call the hook once and branch afterward:

   // IF_PLATFORM react-like
   useNavigate(): (to: string) => void {
-    if (typeof this._redirectMethod === "object") {
-      return this._redirectMethod.useNavigate();
-    } else if (this._redirectMethod === "window") {
-      return (to: string) => window.location.assign(to);
-      // IF_PLATFORM next
-    } else if (this._redirectMethod === "nextjs") {
-      const router = NextNavigation.useRouter();
-      return (to: string) => router.push(to);
-      // END_PLATFORM
-    } else {
-      return (to: string) => { };
-    }
+    // IF_PLATFORM next
+    // Call the hook unconditionally to satisfy React hook rules.
+    const router = NextNavigation.useRouter();
+    // END_PLATFORM
+    if (typeof this._redirectMethod === "object") {
+      return this._redirectMethod.useNavigate();
+    }
+    if (this._redirectMethod === "window") {
+      return (to: string) => window.location.assign(to);
+    }
+    // IF_PLATFORM next
+    if (this._redirectMethod === "nextjs") {
+      return (to: string) => router.push(to);
+    }
+    // END_PLATFORM
+    return (_to: string) => {};
   }
   // END_PLATFORM
♻️ Duplicate comments (1)
packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts (1)

1220-1229: Conditional hook issue fixed — useAsyncCache is now called unconditionally.

This addresses the prior Biome error on conditional hooks in useItem.

🧹 Nitpick comments (3)
packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts (1)

605-609: Wording nit: reference the actual tokenStore values.

Error message currently suggests tokenStore value 'cookies', while the code uses 'cookie' and 'nextjs-cookie'.

Apply this small wording tweak:

-      throw new Error("Cannot call this function on a Stack app without a persistent token store. Make sure the tokenStore option on the constructor is set to a non-null value when initializing Stack.\n\nStack uses token stores to access access tokens of the current user. For example, on web frontends it is commonly the string value 'cookies' for cookie storage.");
+      throw new Error("Cannot call this function on a Stack app without a persistent token store. Make sure the tokenStore option on the constructor is set to a non-null value when initializing Stack.\n\nStack uses token stores to access access tokens of the current user. For example, on web frontends the tokenStore is commonly 'cookie' or 'nextjs-cookie'.");
packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts (1)

517-523: Minor readability refactor: extract owner object before calling updateItemQuantity.

Current nested spread is correct but a bit dense to read.

Apply this small refactor:

-    await this._interface.updateItemQuantity(
-      { itemId: options.itemId, ...("userId" in options ? { userId: options.userId } : ("teamId" in options ? { teamId: options.teamId } : { customId: options.customId })) },
+    const owner =
+      "userId" in options ? { userId: options.userId } :
+      "teamId" in options ? { teamId: options.teamId } :
+                            { customId: options.customId };
+    await this._interface.updateItemQuantity(
+      { itemId: options.itemId, ...owner },
       {
         delta: options.quantity,
         expires_at: options.expiresAt,
         description: options.description,
       }
     );
packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts (1)

1011-1035: Add missing dependencies to useMemo to avoid stale closures.

The returned ServerItem captures type and id inside useMemo but only depends on result. Include id and type to be safe when the discriminator changes.

Apply this minimal fix:

-    return useMemo(() => this._serverItemFromCrud({ type, id }, result), [result]);
+    return useMemo(() => this._serverItemFromCrud({ type, id }, result), [result, id, type]);
📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 1018ee5 and da6f000.

📒 Files selected for processing (6)
  • apps/backend/src/app/api/latest/payments/purchases/create-purchase-url/route.ts (3 hunks)
  • apps/e2e/tests/backend/endpoints/api/v1/payments/create-purchase-url.test.ts (8 hunks)
  • packages/stack-shared/src/interface/client-interface.ts (4 hunks)
  • packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts (1 hunks)
  • packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts (9 hunks)
  • packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts (7 hunks)
🚧 Files skipped from review as they are similar to previous changes (3)
  • apps/e2e/tests/backend/endpoints/api/v1/payments/create-purchase-url.test.ts
  • apps/backend/src/app/api/latest/payments/purchases/create-purchase-url/route.ts
  • packages/stack-shared/src/interface/client-interface.ts
🧰 Additional context used
🧬 Code Graph Analysis (2)
packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts (3)
packages/template/src/lib/stack-app/apps/implementations/common.ts (2)
  • createCacheBySession (29-39)
  • useAsyncCache (145-190)
packages/template/src/lib/stack-app/apps/interfaces/client-app.ts (2)
  • StackClientApp (35-87)
  • StackClientApp (102-102)
packages/template/src/lib/stack-app/customers/index.ts (1)
  • Item (7-17)
packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts (3)
packages/template/src/lib/stack-app/apps/implementations/common.ts (2)
  • createCache (22-27)
  • useAsyncCache (145-190)
packages/template/src/lib/stack-app/customers/index.ts (1)
  • ServerItem (19-33)
packages/stack-shared/src/utils/caches.tsx (1)
  • AsyncCache (67-122)
🪛 Biome (2.1.2)
packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts

[error] 1315-1315: This hook is being called conditionally, but all hooks must be called in the exact same order in every component render.

For React to preserve state between calls, hooks needs to be called unconditionally and always in the same order.
See https://reactjs.org/docs/hooks-rules.html#only-call-hooks-at-the-top-level

(lint/correctness/useHookAtTopLevel)

⏰ 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). (4)
  • GitHub Check: build (22.x)
  • GitHub Check: all-good
  • GitHub Check: build (22.x)
  • GitHub Check: Security Check
🔇 Additional comments (9)
packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts (3)

216-220: Custom item cache wiring looks correct (session-scoped, id+itemId key).

The new _customItemCache follows the same pattern as user/team caches and calls interface.getItem with { customId, itemId }. No concerns.


1202-1203: createCheckoutUrl now passes owner type — aligned with new API.

Forwarding the owner type ("user" | "team") to the interface matches the breaking change in the PR objectives.


1207-1218: Top-level getItem union overload — good addition.

Selecting the correct cache by discriminator and returning the mapped Item is consistent and keeps the fetch centralized.

packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts (2)

511-515: createItemQuantityChange signature updated — matches tri-scope API.

Accepting {userId|teamId|customId, itemId, quantity...} aligns with backend changes. Looks good.


511-515: No remaining usages of createPurchaseUrl detected

Ran a full scan across .ts and .tsx files with:

rg -nP -C2 '\bcreatePurchaseUrl\s*\(' --glob '*.ts' --glob '*.tsx'

No matches found—no further migration needed.

packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts (4)

172-176: Server custom items cache added — consistent with user/team caches.

The cache key and interface call for custom items look correct.


579-580: createCheckoutUrl now includes owner type on server — aligned with interface changes.

Passing "user"/"team" explicitly matches the updated backend signature.

Also applies to: 717-718


722-770: ServerItem construction with tri-scope owner and cache refresh paths — LGTM.

  • updateItemQuantity is called with the correct composite id.
  • Cache refresh targets the correct owner cache per branch.
    No functional issues spotted.

998-1009: Top-level getItem for server with tri-scope discriminator — looks good.

Centralizes server-side retrieval and returns a ServerItem bound to the correct owner context.

@N2D4 N2D4 assigned BilalG1 and unassigned N2D4 Aug 22, 2025
Copy link
Contributor

@coderabbitai coderabbitai bot left a 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/interface/client-interface.ts (1)

1785-1789: Audit and update all createCheckoutUrl call sites for new signature

The createCheckoutUrl method signature was changed to include four parameters (customer_type, customer_id, offerIdOrInline, session). The following call sites still use the old one-argument form and must be updated to avoid runtime errors:

  • apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/page-client.tsx
    Line 130:

    const checkoutUrl = await props.team.createCheckoutUrl("team");

    → Update to:

    const checkoutUrl = await props.team.createCheckoutUrl(
      "team",
      props.team.id,
      offerId,
      session
    );
  • apps/dashboard/src/components/payments/create-checkout-dialog.tsx
    Line 34:

    const result = await Result.fromPromise(customer.createCheckoutUrl(data.offerId));

    → Update to:

    const result = await Result.fromPromise(customer.createCheckoutUrl(
      data.customerType,
      data.customerId,
      data.offerId,
      session
    ));

    Line 62 (form submit):

    onSubmit={values => createCheckoutUrl(values)}

    → Ensure the handler extracts and passes all four parameters.

All other wrappers in packages/template already pass the correct four arguments internally, so only direct calls need correction.

♻️ Duplicate comments (5)
packages/stack-shared/src/interface/server-interface.ts (1)

866-871: Encode path segments; avoid raw template literals for user-supplied IDs.

customerType/customerId/itemId can include characters (e.g., slashes) that break the path or cause route mismatches. Use the existing urlString template tag (or encodeURIComponent per segment) to prevent path injection and 404s. This was flagged earlier; elevating to a correctness/security fix.

Apply one of the following diffs:

Option A — preferred (urlString helper):

-      `/payments/items/${customerType}/${customerId}/${itemId}/update-quantity?${queryParams.toString()}`,
+      urlString`/payments/items/${customerType}/${customerId}/${itemId}/update-quantity?${queryParams.toString()}`,

Option B — explicit encoding:

-      `/payments/items/${customerType}/${customerId}/${itemId}/update-quantity?${queryParams.toString()}`,
+      `/payments/items/${encodeURIComponent(customerType)}/${encodeURIComponent(customerId)}/${encodeURIComponent(itemId)}/update-quantity?${queryParams.toString()}`,
packages/stack-shared/src/interface/client-interface.ts (3)

17-17: Good fix: use urlString for safe path templating (encodes segments).

This import enables encoded path construction and resolves prior concerns about unencoded dynamic segments in item routes.


1776-1778: Path now encodes all dynamic segments.

urlString\/payments/items/${customerType}/${customerId}/${options.itemId}`` correctly encodes every segment. This closes the routing breakage when IDs contain reserved characters.


1796-1801: Follow code-patterns: prefer stringifyJson over JSON.stringify for request bodies.

The repo’s rule requires using stringifyJson to guarantee deterministic serialization and safe error handling.

Apply:

-        body: JSON.stringify({ customer_type, customer_id, ...offerBody }),
+        body: stringifyJson({ customer_type, customer_id, ...offerBody }),

And add/import stringifyJson (outside this hunk):

// near the top with other utils
import { ReadonlyJson, stringifyJson } from '../utils/json';

Check remaining direct uses in this file to avoid future bot comments:

#!/bin/bash
rg -nP --glob '!**/node_modules/**' 'JSON\.stringify\(' packages/stack-shared/src/interface/client-interface.ts
packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts (1)

1224-1231: Hook order fixed: single unconditional useAsyncCache call.

This addresses the previous “conditional hook” lint error for useItem.

🧹 Nitpick comments (6)
packages/stack-shared/src/interface/server-interface.ts (2)

847-863: Guard against multiple identifiers being provided simultaneously.

At runtime, an object could contain more than one of userId/teamId/customCustomerId (e.g., from spread-merging). Today the first matching branch wins silently. Add a defensive check to ensure exactly one is set; fail fast with a clear error.

Apply this diff within this block:

-    if ("userId" in options) {
+    const idsProvided = ["userId", "teamId", "customCustomerId"].filter((k) => k in options);
+    if (idsProvided.length !== 1) {
+      throw new StackAssertionError(`updateItemQuantity requires exactly one of userId, teamId, or customCustomerId (got: ${idsProvided.join(", ") || "none"})`);
+    }
+
+    if ("userId" in options) {
       customerType = "user";
       customerId = options.userId;
     } else if ("teamId" in options) {
       customerType = "team";
       customerId = options.teamId;
     } else if ("customCustomerId" in options) {
       customerType = "custom";
       customerId = options.customCustomerId;
-    } else {
-      throw new StackAssertionError("updateItemQuantity requires one of userId, teamId, or customCustomerId");
-    }
+    }

869-870: Header casing consistency.

The file mixes "content-type" and "Content-Type". HTTP is case-insensitive, but pick one for consistency.

Apply this tiny diff here:

-        headers: { "content-type": "application/json" },
+        headers: { "Content-Type": "application/json" },
packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts (3)

521-522: Replace nested ternary with clearer branching.

The nested ternary with "in" checks is hard to scan and easy to break if extended. Use an if/else chain mirroring server-interface for readability and parity.

Apply this refactor:

-      { itemId: options.itemId, ...("userId" in options ? { userId: options.userId } : ("teamId" in options ? { teamId: options.teamId } : { customCustomerId: options.customCustomerId })) },
+      (() => {
+        if ("userId" in options) return { itemId: options.itemId, userId: options.userId };
+        if ("teamId" in options) return { itemId: options.itemId, teamId: options.teamId };
+        return { itemId: options.itemId, customCustomerId: options.customCustomerId };
+      })(),

515-519: Support decrements via allowNegative or auto-detect.

The server call honors allow_negative via a query param, but this admin method has no way to set it. If quantity can be negative (revokes/credits), calls will fail or be rejected. Either expose allowNegative?: boolean, or infer it when quantity < 0.

Apply this minimal, backward-compatible change:

-  async createItemQuantityChange(options: (
-    { userId: string, itemId: string, quantity: number, expiresAt?: string, description?: string } |
-    { teamId: string, itemId: string, quantity: number, expiresAt?: string, description?: string } |
-    { customCustomerId: string, itemId: string, quantity: number, expiresAt?: string, description?: string }
-  )): Promise<void> {
+  async createItemQuantityChange(options: (
+    { userId: string, itemId: string, quantity: number, expiresAt?: string, description?: string, allowNegative?: boolean } |
+    { teamId: string, itemId: string, quantity: number, expiresAt?: string, description?: string, allowNegative?: boolean } |
+    { customCustomerId: string, itemId: string, quantity: number, expiresAt?: string, description?: string, allowNegative?: boolean }
+  )): Promise<void> {

And include the flag in the payload mapping:

       {
         delta: options.quantity,
         expires_at: options.expiresAt,
         description: options.description,
+        allow_negative: options.allowNegative ?? (options.quantity < 0),
       }

Note: If ItemCrud['Server']['Update'] doesn’t include allow_negative in the body and only as a query param, alternatively plumb it into the first arg and let the interface derive the query param. If you prefer that, I can provide a matching change to the interface method.


515-527: Type hygiene: consider extracting a shared scope type.

Both admin and server interfaces model the same tri-scope shape. Extract a shared type (e.g., ItemQuantityScope) in stack-shared to prevent divergence.

I can prep a small PR to add:

  • packages/stack-shared/src/interface/types.ts: export type ItemQuantityScope = …
  • Use it in both places.
    Would you like me to draft that?
packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts (1)

685-697: Minor: prefer method shorthand to avoid accidental this rebinding.

Defining methods with function () {} relies on call-site binding; destructuring (const { isValid } = key) would break this. Use method shorthand to keep semantics and improve readability.

Apply:

-      isValid: function () {
+      isValid() {
         return this.whyInvalid() === null;
       },
-      whyInvalid: function () {
+      whyInvalid() {
         if (this.manuallyRevokedAt) {
           return "manually-revoked";
         }
         if (this.expiresAt && this.expiresAt < new Date()) {
           return "expired";
         }
         return null;
       },
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 917f7a8 and 509762d.

📒 Files selected for processing (13)
  • apps/dashboard/src/components/data-table/payment-item-table.tsx (4 hunks)
  • apps/e2e/tests/backend/backend-helpers.ts (1 hunks)
  • apps/e2e/tests/backend/endpoints/api/v1/payments/items.test.ts (14 hunks)
  • apps/e2e/tests/js/payments.test.ts (3 hunks)
  • packages/stack-shared/src/interface/client-interface.ts (3 hunks)
  • packages/stack-shared/src/interface/server-interface.ts (1 hunks)
  • packages/stack-shared/src/known-errors.tsx (2 hunks)
  • packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts (1 hunks)
  • packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts (9 hunks)
  • packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts (5 hunks)
  • packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts (1 hunks)
  • packages/template/src/lib/stack-app/apps/interfaces/client-app.ts (2 hunks)
  • packages/template/src/lib/stack-app/apps/interfaces/server-app.ts (2 hunks)
🚧 Files skipped from review as they are similar to previous changes (9)
  • packages/template/src/lib/stack-app/apps/interfaces/server-app.ts
  • apps/e2e/tests/backend/backend-helpers.ts
  • packages/template/src/lib/stack-app/apps/interfaces/client-app.ts
  • packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts
  • apps/e2e/tests/backend/endpoints/api/v1/payments/items.test.ts
  • apps/dashboard/src/components/data-table/payment-item-table.tsx
  • packages/stack-shared/src/known-errors.tsx
  • packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts
  • apps/e2e/tests/js/payments.test.ts
🧰 Additional context used
📓 Path-based instructions (1)
**/*.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

Prefer ES6 Map over Record where feasible

Files:

  • packages/stack-shared/src/interface/client-interface.ts
  • packages/stack-shared/src/interface/server-interface.ts
  • packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts
  • packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts
🧬 Code graph analysis (3)
packages/stack-shared/src/interface/client-interface.ts (2)
packages/stack-shared/src/utils/errors.tsx (1)
  • StackAssertionError (69-85)
packages/stack-shared/src/utils/urls.tsx (1)
  • urlString (314-316)
packages/stack-shared/src/interface/server-interface.ts (2)
packages/stack-shared/src/interface/crud/items.ts (1)
  • ItemCrud (24-24)
packages/stack-shared/src/utils/errors.tsx (1)
  • StackAssertionError (69-85)
packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts (3)
packages/template/src/lib/stack-app/apps/implementations/common.ts (2)
  • createCacheBySession (29-39)
  • useAsyncCache (145-190)
packages/template/src/lib/stack-app/apps/interfaces/client-app.ts (2)
  • StackClientApp (35-87)
  • StackClientApp (102-102)
packages/template/src/lib/stack-app/customers/index.ts (1)
  • Item (7-17)
🪛 Biome (2.1.2)
packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts

[error] 1318-1318: This hook is being called conditionally, but all hooks must be called in the exact same order in every component render.

For React to preserve state between calls, hooks needs to be called unconditionally and always in the same order.
See https://reactjs.org/docs/hooks-rules.html#only-call-hooks-at-the-top-level

(lint/correctness/useHookAtTopLevel)

⏰ 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). (8)
  • GitHub Check: setup-tests
  • GitHub Check: all-good
  • GitHub Check: restart-dev-and-test
  • GitHub Check: docker
  • GitHub Check: lint_and_build (latest)
  • GitHub Check: build (22.x)
  • GitHub Check: build (22.x)
  • GitHub Check: Security Check
🔇 Additional comments (10)
packages/stack-shared/src/interface/server-interface.ts (3)

839-846: Public API shape looks good; union captures all three scopes.

The new options-union is a sensible direction and keeps call sites explicit. Nice alignment with backend route shape.


839-871: Confirm that the backend route for /payments/items/:customerType/:customerId/:itemId/update-quantity accepts lowercase path segments

I wasn’t able to locate the HTTP handler or any normalization logic for customerType in this repo. Please verify that:

  • The API route file (e.g. apps/backend/src/app/api/payments/items/[customerType]/[customerId]/[itemId]/update-quantity/route.ts) is defined and matches lowercase segments (user, team, custom).
  • Any validator or Zod schema around customerType allows lowercase values or explicitly converts them (e.g. req.params.customerType.toUpperCase()).
  • Before passing req.params.customerType into Prisma (where the CustomerType enum values are USER, TEAM, CUSTOM), the string is normalized to uppercase.

Ensuring case alignment will prevent 404s or enum casting errors when calling updateItemQuantity.


839-871: All updateItemQuantity calls use the new two-argument signature.
I ran both AST-based and regex searches and found no remaining invocations passing three positional arguments.

packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts (1)

515-519: API surface aligns with server-interface; good.

Accepting userId|teamId|customCustomerId in a single union reduces overload churn and matches the server API evolution.

packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts (6)

219-223: Add custom-item cache: good separation and reuse of shared getItem.

The _customItemCache mirrors user/team caches and routes through the interface. Looks correct.


264-272: OAuth scope-adding redirect path wires in new scopes correctly.

Passing merged providerScope and using app URLs here is consistent with the flow. No issues spotted.


760-763: Team update triggers cache refresh appropriately.

Updating team data and then refreshing the current user teams cache is correct.


1097-1100: Password update: refresh user cache after mutation.

Cache invalidation added; good.


1205-1206: New createCheckoutUrl signature used correctly.

Passing type and owner ID matches the updated interface contract.


608-612: Helpful error message when token store is non-persistent.

Clear guidance; assertion narrowing is appropriate.

(https://www.loom.com/share/ab23e3ed0dde44bcb791d30b3b012777?sid=2ee6dcb4-feca-4d0f-9c8d-1ae9d71ba8f7)
<!--



Make sure you've read the CONTRIBUTING.md guidelines:
https://github.com/stack-auth/stack-auth/blob/dev/CONTRIBUTING.md

-->

<!-- ELLIPSIS_HIDDEN -->


----

> [!IMPORTANT]
> Adds management pages for payment offers and items with create, edit,
and delete functionalities, and updates schemas for default values and
validation.
> 
>   - **Behavior**:
> - Adds `page-client.tsx` and `page.tsx` for both `items` and `offers`
under `payments` to manage payment items and offers.
> - Implements `DialogOpener` in `page-client.tsx` for creating new
items and offers.
> - Adds edit and delete functionality for items and offers in
`payment-item-table.tsx` and `payment-offer-table.tsx`.
> - Updates `ensureOfferIdOrInlineOffer` in `payments.tsx` to default
`quantity` to 0 if undefined.
>   - **Schema**:
> - Updates `branchPaymentsSchema` in `schema.ts` to set default
`quantity` to 0 for items.
> - Modifies `offerSchema` and `inlineOfferSchema` in `schema-fields.ts`
to define `quantity` as required.
>   - **UI Components**:
> - Introduces `ItemDialog` and `OfferDialog` for item and offer
creation/editing.
>     - Adds `DialogOpener` component for handling dialog state.
> - Updates `DayIntervalSelectorField` to include an unset label option.
>   - **Navigation**:
> - Adds "Offers" and "Items" to the sidebar in `sidebar-layout.tsx`
with feature flag checks.
> 
> <sup>This description was created by </sup>[<img alt="Ellipsis"
src="https://img.shields.io/badge/Ellipsis-blue?color=175173">](https://www.ellipsis.dev?ref=stack-auth%2Fstack-auth&utm_source=github&utm_medium=referral)<sup>
for c22bf3c. You can
[customize](https://app.ellipsis.dev/stack-auth/settings/summaries) this
summary. It will automatically update as commits are pushed.</sup>


<!-- ELLIPSIS_HIDDEN -->
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (6)
apps/backend/src/lib/stripe.tsx (1)

45-47: Allow CUSTOM customerType in Stripe sync and creation flows

The current implementation rejects any Stripe customer whose metadata.customerType isn’t USER or TEAM, and in the purchase-URL route new customers with customer_type: "custom" are still created with metadata.customerType = TEAM. To fully support the new CUSTOM type end-to-end, please:

  • In apps/backend/src/lib/stripe.tsx (syncStripeSubscriptions), include CustomerType.CUSTOM in the allowed types.
  • In apps/backend/src/app/api/latest/payments/purchases/create-purchase-url/route.ts, map "custom" to CustomerType.CUSTOM when creating a Stripe customer.
  • Update error messages and tests accordingly to reflect that CUSTOM is now a first-class customer type.

Required changes:

  • apps/backend/src/lib/stripe.tsx (around line 45)

     if (customerType !== CustomerType.USER && customerType !== CustomerType.TEAM) {
       throw new StackAssertionError("Stripe customer metadata has invalid customerType");
     }

    should become:

  • if (customerType !== CustomerType.USER && customerType !== CustomerType.TEAM) {

  • if (![CustomerType.USER, CustomerType.TEAM, CustomerType.CUSTOM].includes(customerType)) {
    throw new StackAssertionError("Stripe customer metadata has invalid customerType");
    }

- **apps/backend/src/app/api/latest/payments/purchases/create-purchase-url/route.ts** (around the `stripe.customers.create` call)

```diff
-    if (!stripeCustomer) {
-      stripeCustomer = await stripe.customers.create({
-        metadata: {
-          customerId: req.body.customer_id,
-          customerType: customerType === "user" ? CustomerType.USER : CustomerType.TEAM,
-        }
-      });
-    }
+    if (!stripeCustomer) {
+      // Map the string customerType to the Prisma enum, including CUSTOM
+      const stripeMetadataCustomerType =
+        customerType === "user"
+          ? CustomerType.USER
+          : customerType === "team"
+          ? CustomerType.TEAM
+          : CustomerType.CUSTOM;
+
+      stripeCustomer = await stripe.customers.create({
+        metadata: {
+          customerId: req.body.customer_id,
+          customerType: stripeMetadataCustomerType,
+        },
+      });
+    }
  • Ensure any checkout-session creation logic that stamps subscription metadata continues to serialize and parse CUSTOM correctly.
  • Add or update tests to cover the CUSTOM flow (e.g., in apps/e2e/tests/js/payments.test.ts) so we verify sync, retrieval, and quantity logic for custom customers.
apps/backend/prisma/migrations/20250821175509_test_mode_subscriptions/migration.sql (1)

1-13: Unsafe NOT NULL addition without default will fail on non-empty Subscription table

Postgres cannot add a NOT NULL column without a default if the table has rows. This migration will fail in environments with existing data. Provide a default, backfill, then (optionally) drop the default.

Safer multi-step migration:

 /*
   Warnings:
 
   - Added the required column `creationSource` to the `Subscription` table without a default value. This is not possible if the table is not empty.
 */
 -- CreateEnum
 CREATE TYPE "SubscriptionCreationSource" AS ENUM ('PURCHASE_PAGE', 'TEST_MODE');

 -- AlterTable
-ALTER TABLE "Subscription" ADD COLUMN     "creationSource" "SubscriptionCreationSource" NOT NULL,
-ALTER COLUMN "stripeSubscriptionId" DROP NOT NULL;
+ALTER TABLE "Subscription"
+  ADD COLUMN "creationSource" "SubscriptionCreationSource" NOT NULL DEFAULT 'PURCHASE_PAGE',
+  ALTER COLUMN "stripeSubscriptionId" DROP NOT NULL;
+
+-- Backfill is instant due to DEFAULT; if desired, drop the DEFAULT to enforce explicit writes going forward:
+ALTER TABLE "Subscription" ALTER COLUMN "creationSource" DROP DEFAULT;
apps/backend/src/app/api/latest/payments/purchases/purchase-session/route.tsx (3)

53-61: Validate and integerize amount before sending to Stripe

Number(selectedPrice.USD) * 100 can produce NaN or non-integer values. Stripe requires a positive integer in the smallest currency unit. Validate and round explicitly to avoid 4xx from Stripe or silent rounding.

-      items: [{
-        price_data: {
-          currency: "usd",
-          unit_amount: Number(selectedPrice.USD) * 100,
+      items: [{
+        price_data: {
+          currency: "usd",
+          // Validate and integerize price
+          unit_amount: (() => {
+            const usd = Number(selectedPrice.USD);
+            if (!Number.isFinite(usd) || usd <= 0) {
+              throw new StatusError(400, "Invalid USD price amount on offer");
+            }
+            return Math.round(usd * 100);
+          })(),
           product: product.id,
           recurring: {
             interval_count: selectedPrice.interval[0],
             interval: selectedPrice.interval[1],
           },

47-67: Add Stripe idempotency to avoid duplicate subscriptions on retries

If the client retries before code revocation (network glitches, reloads), you can end up with multiple subscriptions. Use the verification codeId as an idempotency key.

-    const subscription = await stripe.subscriptions.create({
+    const subscription = 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,
           recurring: {
             interval_count: selectedPrice.interval[0],
             interval: selectedPrice.interval[1],
           },
         },
         quantity: 1,
       }],
       metadata: {
         offer: JSON.stringify(data.offer),
       },
-    });
+    }, { idempotencyKey: `purchase-session:${codeId}` });

51-52: Critical Fix Required: Correct Stripe expand path for client secret

The expansion key currently used (latest_invoice.confirmation_secret) is invalid. According to Stripe’s documentation, you must expand the payment_intent.client_secret field on the latest invoice to retrieve the client secret.

• File: apps/backend/src/app/api/latest/payments/purchases/purchase-session/route.tsx
– Lines 51–52 and 73–77: update the expand array.

Suggested change:

- expand: ['latest_invoice.confirmation_secret'],
+ expand: ['latest_invoice.payment_intent.client_secret'],

Please apply this update to ensure the client secret is correctly returned.

apps/dashboard/src/components/form-fields/day-interval-selector-field.tsx (1)

29-32: Format and display "never" correctly

formatDayInterval ignores "never", so the Select won’t show it as selected. Include it explicitly.

-  const formatDayInterval = (interval: DayInterval | undefined): string | undefined => {
-    if (!interval) return undefined;
+  const formatDayInterval = (interval: DayInterval | "never" | undefined): string | undefined => {
+    if (!interval) return undefined;
+    if (interval === "never") return "never";
     return `${interval[0]}-${interval[1]}`;
   };
@@
-              defaultValue={formatDayInterval(field.value as DayInterval)}
+              defaultValue={formatDayInterval(field.value as DayInterval | "never")}

Also applies to: 43-45

♻️ Duplicate comments (2)
apps/backend/src/lib/payments.tsx (1)

64-65: Ensure all getItemQuantityForCustomer call sites pass customerType.

The signature now requires customerType. Please verify all invocations were updated; older sites may still omit it.

Run to list argument objects and quickly spot missing keys:

#!/bin/bash
# Show all call sites and the argument object for getItemQuantityForCustomer
rg -nP -C2 --type=ts '\bgetItemQuantityForCustomer\s*\(\s*\{[^}]*\}\s*\)' \
  | sed -n '1,200p'
# Specifically highlight lines lacking 'customerType'
rg -nP --type=ts '\bgetItemQuantityForCustomer\s*\(\s*\{(?![^}]*\bcustomerType\b)' -C1
apps/dashboard/src/components/data-table/payment-item-table.tsx (1)

148-154: Make customerType required; don’t silently fall back to “custom”.

Leaving customerType optional means undefined flows to the “custom” branch in submit. Make it required in props and guard before submission.

 type CreateItemQuantityChangeDialogProps = {
   open: boolean,
   onOpenChange: (open: boolean) => void,
   itemId: string,
-  customerType: "user" | "team" | "custom" | undefined,
+  customerType: "user" | "team" | "custom",
 }

And in submit (below), add a defensive guard (see next comment).

🧹 Nitpick comments (36)
apps/dashboard/src/lib/utils.tsx (1)

24-29: Good move to safer parsing; add a narrow-to-string[] guard to avoid accidental non-string entries.

parseJson shields against throws, but JSON can still be an array with non-string elements. Filter to string[] before includes to keep behavior predictable.

Apply:

-  const allowedProjectIds = parseJson(getPublicEnvVar("NEXT_PUBLIC_STACK_ENABLE_DEVELOPMENT_FEATURES_PROJECT_IDS") || "[]");
-  if (allowedProjectIds.status !== "ok" || !Array.isArray(allowedProjectIds.data)) {
-    return false;
-  }
-  return allowedProjectIds.data.includes(projectId);
+  const parsed = parseJson(getPublicEnvVar("NEXT_PUBLIC_STACK_ENABLE_DEVELOPMENT_FEATURES_PROJECT_IDS") || "[]");
+  if (parsed.status !== "ok" || !Array.isArray(parsed.data)) {
+    return false;
+  }
+  const ids = parsed.data.filter((v): v is string => typeof v === "string");
+  return ids.includes(projectId);

Optional: normalize whitespace to reduce surprises from hand-edited envs:

-  const ids = parsed.data.filter((v): v is string => typeof v === "string");
+  const ids = parsed.data
+    .filter((v): v is string => typeof v === "string")
+    .map(s => s.trim());
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/page-client.tsx (2)

80-84: Gate rendering of Connect UI until setup is complete; also avoid inline style.

  • Rendering <ConnectPayments /> when stripeAccountSetupComplete is false can produce confusing UX or errors, depending on how your Connect instance is configured.
  • Prefer Tailwind classes over inline style for consistency and easier theming.

Apply this diff:

-      <div className="flex justify-center">
-        <div style={{ maxWidth: 1250, width: '100%' }}>
-          <ConnectPayments />
-        </div>
-      </div>
+      {paymentsConfig.stripeAccountSetupComplete ? (
+        <div className="flex justify-center">
+          <div className="w-full max-w-[1250px]">
+            <ConnectPayments />
+          </div>
+        </div>
+      ) : (
+        <div className="flex justify-center">
+          <Button onClick={setupPayments}>Complete Setup</Button>
+        </div>
+      )}

If the page is not already wrapped by ConnectComponentsProvider, you’ll also need to wrap the component:

// outside the selected range; illustrative only
import { ConnectComponentsProvider, ConnectPayments } from "@stripe/react-connect-js";
import { loadConnectAndInitialize, type ConnectInstance } from "@stripe/connect-js";
import { useEffect, useState } from "react";

function PaymentsPanel() {
  const [connectInstance, setConnectInstance] = useState<ConnectInstance | null>(null);

  useEffect(() => {
    let mounted = true;
    (async () => {
      // TODO: replace with your real publisher key and client-secret fetcher
      const instance = await loadConnectAndInitialize({
        publishableKey: process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!,
        fetchClientSecret: async () => (await fetch("/api/stripe/connect/client-secret")).text(),
      });
      if (mounted) setConnectInstance(instance);
    })();
    return () => { mounted = false; };
  }, []);

  if (!connectInstance) return null; // or a skeleton

  return (
    <ConnectComponentsProvider connectInstance={connectInstance}>
      <ConnectPayments />
    </ConnectComponentsProvider>
  );
}

26-30: Add error handling around payments setup redirect.

A network failure or server error from setupPayments() will currently surface as an unhandled promise rejection. Show a toast and avoid redirect attempts when the call fails.

-  const setupPayments = async () => {
-    const { url } = await stackAdminApp.setupPayments();
-    window.location.href = url;
-    await wait(2000);
-  };
+  const setupPayments = async () => {
+    try {
+      const { url } = await stackAdminApp.setupPayments();
+      window.location.href = url;
+      await wait(2000);
+    } catch (err: any) {
+      toast({
+        title: "Failed to start payments setup",
+        description: err?.message ?? "Please try again.",
+        variant: "destructive",
+      });
+    }
+  };
apps/backend/src/lib/stripe.tsx (1)

87-87: Prefer Prisma enum over string literal for creationSource

Use the generated Prisma enum to avoid drift and get compile-time checks.

-import { CustomerType } from "@prisma/client";
+import { CustomerType, SubscriptionCreationSource } from "@prisma/client";-        creationSource: "PURCHASE_PAGE"
+        creationSource: SubscriptionCreationSource.PURCHASE_PAGE
apps/dashboard/tailwind.config.ts (1)

80-81: Consider reduced-motion accessibility for fade-in

If these animations are applied broadly, prefer guarding via motion-safe or providing motion-reduce variants in usage to respect prefers-reduced-motion.

Example usage in components:

  • className="motion-safe:animate-fade-in"
  • or className="animate-fade-in motion-reduce:animate-none"
apps/backend/prisma/migrations/20250821175509_test_mode_subscriptions/migration.sql (1)

1-13: Confirm and Enforce Single TEST_MODE Subscription per Tenancy

We’ve dropped NOT NULL on stripeSubscriptionId in apps/backend/prisma/migrations/20250821175509_test_mode_subscriptions/migration.sql (ALTER COLUMN "stripeSubscriptionId" DROP NOT NULL) while preserving the composite unique on (tenancyId, stripeSubscriptionId) in your Prisma schema (@@unique([tenancyId, stripeSubscriptionId]) in apps/backend/prisma/schema.prisma at line 755). In Postgres, a UNIQUE index treats multiple NULLs as distinct, so this change will allow multiple TEST_MODE subscriptions (which rely on stripeSubscriptionId = NULL) for the same tenancy.

• If you’re relying on this composite unique for Prisma upserts, ensure you never upsert with stripeSubscriptionId: null, otherwise each upsert will insert a new TEST_MODE row.
• To enforce “one TEST_MODE subscription per tenancy” at the database level, add a partial unique index in a follow-up migration:

CREATE UNIQUE INDEX "Subscription_tenancyId_testMode_key"
  ON "Subscription"("tenancyId")
  WHERE "creationSource" = 'TEST_MODE';

• Alternatively, enforce this invariant in application logic (e.g. throw or upsert only if no existing TEST_MODE row exists).

By adding a targeted constraint (or guarding in code), you’ll prevent unintended duplicates of TEST_MODE subscriptions per tenancy.

apps/dashboard/src/components/dialog-opener.tsx (1)

20-22: Avoid accidental form submission by setting Button type="button"

If DialogOpener is used inside a form, the Button may submit the form depending on the underlying implementation. Safer to set an explicit type.

-        <Button onClick={() => setIsOpen(true)}>
+        <Button type="button" onClick={() => setIsOpen(true)}>
           {triggerLabel}
         </Button>
apps/backend/src/app/api/latest/internal/payments/test-mode-purchase-session/route.tsx (3)

40-42: Return a 501 StatusError instead of throwing an assertion for unsupported one-time prices.

This is a feature gap, not a server invariant violation. Using 501 communicates intent better and yields a client error payload consistent with StatusError.

Apply this diff:

-    if (!selectedPrice.interval) {
-      throw new StackAssertionError("unimplemented; prices without an interval are currently not supported");
-    }
+    if (!selectedPrice.interval) {
+      throw new StatusError(StatusError.NotImplemented, "Prices without an interval are not yet supported in test mode");
+    }

50-52: Avoid micro-drift between start and end timestamps.

Compute currentPeriodEnd from the same base timestamp used for currentPeriodStart.

Apply this diff:

-    await prisma.subscription.create({
+    const now = new Date();
+    await prisma.subscription.create({
       data: {
         tenancyId: auth.tenancy.id,
         customerId: data.customerId,
         customerType: typedToUppercase(data.offer.customerType),
         status: "active",
         offer: data.offer,
-        currentPeriodStart: new Date(),
-        currentPeriodEnd: addInterval(new Date(), selectedPrice.interval),
+        currentPeriodStart: now,
+        currentPeriodEnd: addInterval(now, selectedPrice.interval),
         cancelAtPeriodEnd: false,
         creationSource: "TEST_MODE",
       },
     });

43-55: Consider including the selected price id in the created subscription for traceability.

If your Subscription model or metadata has a field for the chosen price id, persisting price_id helps auditing and post-hoc reconciliation in test mode. If no such field exists, ignore this.

apps/dashboard/src/components/payments/included-item-editor.tsx (1)

72-72: Offer an explicit “No repeat” unset option for UX parity with the price editor.

DayIntervalSelectorField now supports unsetLabel. Since repeat is optional, exposing an unset choice clarifies intent and aligns with the price editor’s “One time”.

Apply this diff:

-            <DayIntervalSelectorField control={sub.control} name={"repeat"} label="Repeat" includeNever />
+            <DayIntervalSelectorField control={sub.control} name={"repeat"} label="Repeat" includeNever unsetLabel="No repeat" />
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx (1)

230-245: Broaden regex to keep nav selection active on nested routes.

Current regexes only match the list pages exactly. If you later add nested routes (e.g., /payments/items/[itemId] or /payments/offers/[offerId]), the sidebar item will not appear selected. Consider allowing optional subpaths.

Apply this diff:

   {
     name: "Offers",
     href: "/payments/offers",
-    regex: /^\/projects\/[^\/]+\/payments\/offers$/,
+    regex: /^\/projects\/[^\/]+\/payments\/offers(?:\/.*)?$/,
     icon: SquarePen,
     type: 'item',
     requiresDevFeatureFlag: true,
   },
   {
     name: "Items",
     href: "/payments/items",
-    regex: /^\/projects\/[^\/]+\/payments\/items$/,
+    regex: /^\/projects\/[^\/]+\/payments\/items(?:\/.*)?$/,
     icon: Box,
     type: 'item',
     requiresDevFeatureFlag: true,
   },
packages/stack-shared/src/schema-fields.ts (1)

574-579: Quantity in includedItems is now required — verify parity with inline schema.

offerSchema.includedItems[...].quantity is defined(), but the corresponding field in inlineOfferSchema remains optional. If this discrepancy is unintentional, align both.

Apply this diff if you want parity:

   included_items: yupRecord(
     userSpecifiedIdSchema("itemId"),
     yupObject({
-      quantity: yupNumber(),
+      quantity: yupNumber().defined(),
       repeat: dayIntervalOrNeverSchema.optional(),
       expires: yupString().oneOf(['never', 'when-purchase-expires', 'when-repeated']).optional(),
     }),
   ),

If the intent is for inline offers to be looser, consider adding a comment to document the divergence.

apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offers/page.tsx (1)

5-7: Optional: Type the metadata

Annotate for better editor help and consistency with Next.js typing.

-import { notFound } from "next/navigation";
+import { notFound } from "next/navigation";
+import type { Metadata } from "next";
@@
-export const metadata = {
+export const metadata: Metadata = {
   title: "Offers",
 };
apps/backend/src/app/api/latest/payments/purchases/purchase-session/route.tsx (2)

68-71: Harden code revocation (retry/log) to avoid inconsistencies

If revocation fails after creating a subscription, the client gets a 500 and the code remains reusable. Consider best-effort revocation with logging and retry while still returning success if Stripe creation succeeded.

-    await purchaseUrlVerificationCodeHandler.revokeCode({
-      tenancy,
-      id: codeId,
-    });
+    try {
+      await purchaseUrlVerificationCodeHandler.revokeCode({ tenancy, id: codeId });
+    } catch (e) {
+      console.warn("Failed to revoke purchase verification code", { codeId, tenancyId: tenancy.id, error: (e as Error).message });
+      // Optionally: enqueue a background retry here
+    }

65-66: Consider avoiding large metadata payloads

offer: JSON.stringify(data.offer) risks exceeding Stripe metadata limits (key/value size). Prefer storing a compact identifier (e.g., offer id/version) and fetch details server-side when needed.

apps/dashboard/src/app/(main)/purchase/return/page-client.tsx (1)

94-96: Guard retry link when purchaseFullCode is missing

If purchaseFullCode is undefined, the link points to /purchase/undefined. Hide or disable the link in that case.

-            <StyledLink href={`/purchase/${purchaseFullCode}`}>Click here</StyledLink> to try making your purchase again.
+            {purchaseFullCode ? (
+              <StyledLink href={`/purchase/${purchaseFullCode}`}>Click here</StyledLink>
+            ) : (
+              <span>Return to the purchase page</span>
+            )} to try making your purchase again.
apps/dashboard/src/components/form-fields/day-interval-selector-field.tsx (1)

34-66: Optional: Control the Select value instead of using defaultValue

Using defaultValue with RHF can desync UI from form state after the first change. Prefer passing value={formatDayInterval(field.value as DayInterval | "never")}.

-              defaultValue={formatDayInterval(field.value as DayInterval | "never")}
+              value={formatDayInterval(field.value as DayInterval | "never")}
packages/stack-shared/src/interface/admin-interface.ts (1)

519-529: Optional: Bubble up non-2xx errors explicitly

If sendAdminRequest doesn’t throw on non-OK, consider asserting response.ok and surfacing backend error text for better DX.

-  async testModePurchase(options: { price_id: string, full_code: string }): Promise<void> {
-    await this.sendAdminRequest(
+  async testModePurchase(options: { price_id: string, full_code: string }): Promise<void> {
+    const res = await this.sendAdminRequest(
       "/internal/payments/test-mode-purchase-session",
       {
         method: "POST",
         headers: { "content-type": "application/json" },
         body: JSON.stringify(options),
       },
       null,
     );
+    if (!res.ok) {
+      throw new Error(`testModePurchase failed: ${res.status} ${await res.text()}`);
+    }
   }
apps/backend/src/app/api/latest/payments/purchases/validate-code/route.ts (3)

32-35: Tenancy lookup error type: confirm desired client semantics vs internal assertion

Throwing StackAssertionError here will typically surface as a 5xx/internal assertion. If a stale/invalid code can realistically reach this handler (e.g., revoked tenancy, migration gaps), consider returning a 404/400 known error instead to avoid flagging it as an internal bug. If this path truly represents an invariant violation, keep the assertion—but please confirm that this is intentional.

Example change if you want a client-facing 404:

-    if (!tenancy) {
-      throw new StackAssertionError(`No tenancy found for given tenancyId`);
-    }
+    if (!tenancy) {
+      // Consider a known error/404 here instead of an assertion
+      return {
+        statusCode: 404,
+        bodyType: "json",
+        body: "Tenancy not found for the verification code",
+      };
+    }

39-39: customer_type no longer falls back to "user" — verify backward compatibility of older codes

You now propagate offer.customerType directly. If any historical verification codes were generated without customerType, this can become undefined and violate the response schema. If such codes are impossible, all good; otherwise consider a safe fallback.

-      customer_type: offer.customerType,
+      customer_type: offer.customerType ?? "user",

2-2: Remove unused imports

adaptSchema and clientOrHigherAuthTypeSchema are imported but unused in this file.

-import { adaptSchema, clientOrHigherAuthTypeSchema, inlineOfferSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
+import { inlineOfferSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
apps/backend/prisma/schema.prisma (1)

758-771: Add composite index including customerType for common read-paths

Item quantity lookups often include customerType + customerId. Consider indexing (tenancyId, customerType, customerId, expiresAt) to improve selectivity vs the current (tenancyId, customerId, expiresAt).

 model ItemQuantityChange {
   id           String       @default(uuid()) @db.Uuid
   tenancyId    String       @db.Uuid
   customerType CustomerType
   customerId   String
   itemId       String
   quantity     Int
   description  String?
   expiresAt    DateTime?
   createdAt    DateTime     @default(now())

   @@id([tenancyId, id])
-  @@index([tenancyId, customerId, expiresAt])
+  @@index([tenancyId, customerId, expiresAt])
+  @@index([tenancyId, customerType, customerId, expiresAt])
 }
apps/dashboard/src/components/payments/item-dialog.tsx (1)

88-88: Expose "Never" option for repeat interval (schema supports it)

The validation allows dayIntervalOrNeverSchema but the UI doesn’t offer “Never”. Consider enabling it for consistency.

-                <DayIntervalSelectorField control={form.control} name={"defaultRepeat"} label="Repeat" unsetLabel="None" />
+                <DayIntervalSelectorField control={form.control} name={"defaultRepeat"} label="Repeat" unsetLabel="None" includeNever />
apps/e2e/tests/backend/endpoints/api/v1/payments/purchase-session.test.ts (1)

1-305: Add one E2E for the new "custom" customer type

Given the introduction of CUSTOM end-to-end, consider adding a test mirroring the “user” path, but with a customCustomerId and a custom-typed item to ensure all layers (create URL → purchase session/test-mode → items API) work end-to-end.

Proposed test (append to this file):

+it("creates subscription in test mode for custom customer and increases included item quantity", async ({ expect }) => {
+  await Project.createAndSwitch();
+  await Project.updateConfig({
+    payments: {
+      stripeAccountId: "acct_test123",
+      items: {
+        "custom-item": {
+          displayName: "Custom Item",
+          customerType: "custom",
+          default: { quantity: 0 },
+        },
+      },
+      offers: {
+        "custom-offer": {
+          displayName: "Custom Offer",
+          customerType: "custom",
+          serverOnly: false,
+          stackable: false,
+          prices: {
+            monthly: { USD: "1000", interval: [1, "month"] },
+          },
+          includedItems: { "custom-item": { quantity: 3 } },
+        },
+      },
+    },
+  });
+
+  const customId = "acme-42";
+  const createUrlResponse = await niceBackendFetch("/api/latest/payments/purchases/create-purchase-url", {
+    method: "POST",
+    accessType: "client",
+    body: {
+      customer_type: "custom",
+      customer_id: customId,
+      offer_id: "custom-offer",
+    },
+  });
+  expect(createUrlResponse.status).toBe(200);
+  const body = createUrlResponse.body as { url: string };
+  const code = body.url.match(/\/purchase\/([a-z0-9-_]+)/)?.[1];
+  expect(code).toBeDefined();
+
+  const before = await niceBackendFetch(`/api/latest/payments/items/custom/${customId}/custom-item`, { accessType: "client" });
+  expect(before.status).toBe(200);
+  expect(before.body.quantity).toBe(0);
+
+  const sessionRes = await niceBackendFetch("/api/latest/internal/payments/test-mode-purchase-session", {
+    method: "POST",
+    accessType: "admin",
+    body: { full_code: code, price_id: "monthly" },
+  });
+  expect(sessionRes.status).toBe(200);
+
+  const after = await niceBackendFetch(`/api/latest/payments/items/custom/${customId}/custom-item`, { accessType: "client" });
+  expect(after.status).toBe(200);
+  expect(after.body.quantity).toBe(3);
+});
apps/dashboard/src/app/(main)/purchase/[code]/page-client.tsx (2)

43-48: Guard against missing USD amounts to avoid NaN and invalid Stripe amounts

If a price lacks USD (or contains a non-numeric string), Number(undefined) yields NaN and StripeElementsProvider receives an invalid amount. Fail closed or add a currency selection strategy.

-  const currentAmount = useMemo(() => {
-    if (!selectedPriceId || !data?.offer?.prices) {
-      return 0;
-    }
-    return Number(data.offer.prices[selectedPriceId].USD) * 100;
-  }, [data, selectedPriceId]);
+  const currentAmount = useMemo(() => {
+    if (!selectedPriceId || !data?.offer?.prices) return 0;
+    const usd = data.offer.prices[selectedPriceId]?.USD;
+    return usd ? Math.round(Number(usd) * 100) : 0; // default to 0 if USD is absent
+  }, [data, selectedPriceId]);

If multi-currency is planned, we should thread currency alongside amount and configure Stripe accordingly.


98-107: Bypass handler: consider disabling button until selectedPriceId is set

Minor UX: to avoid a no-op click path, disable the bypass button while selectedPriceId is null/undefined.

You can pass disabled={!selectedPriceId} to the Button in BypassInfo, or early-return with a toast.

apps/dashboard/src/components/data-table/payment-offer-table.tsx (3)

51-51: Consider Map over Record for offers prop (guideline).

The component currently takes offers as a Record. If feasible, prefer ES6 Map to align with codebase guidance and avoid accidental key collisions with prototype properties.

Example refactor sketch:

-export function PaymentOfferTable({ offers }: { offers: Record<string, yup.InferType<typeof branchPaymentsSchema>["offers"][string]> }) {
-  const data: PaymentOffer[] = Object.entries(offers).map(([id, offer]) => ({
+export function PaymentOfferTable({ offers }: {
+  offers: Map<string, yup.InferType<typeof branchPaymentsSchema>["offers"][string]> | Record<string, yup.InferType<typeof branchPaymentsSchema>["offers"][string]>
+}) {
+  const entries = offers instanceof Map ? [...offers.entries()] : Object.entries(offers);
+  const data: PaymentOffer[] = entries.map(([id, offer]) => ({
     id,
     ...offer,
   }));

34-38: Free Trial rendering: prefer readable formatter over array join.

If available in UI utilities, use a readable interval formatter instead of freeTrial?.join(" "). This avoids coupling to tuple shape and keeps display consistent with other places (e.g., PriceEditor).

- cell: ({ row }) => <TextCell>{row.original.freeTrial?.join(" ") ?? ""}</TextCell>,
+ cell: ({ row }) => <TextCell>{row.original.freeTrial ? readableInterval(row.original.freeTrial) : ""}</TextCell>,

Note: add the corresponding import if readableInterval is available in your shared utils.


96-109: Deletion should also clean up references in exclusivity groups.

Offers can be referenced under payments.exclusivityGroups. Consider removing the offer id from any groups before/while deleting to prevent stale config references.

-          onClick: async () => {
-            await project.updateConfig({ [`payments.offers.${offer.id}`]: null });
+          onClick: async () => {
+            const cfg = await project.getConfig();
+            const updates: Record<string, any> = { [`payments.offers.${offer.id}`]: null };
+            for (const [groupId, group] of Object.entries(cfg.payments.exclusivityGroups ?? {})) {
+              if (group && Object.prototype.hasOwnProperty.call(group, offer.id)) {
+                updates[`payments.exclusivityGroups.${groupId}.${offer.id}`] = undefined;
+              }
+            }
+            await project.updateConfig(updates);
             toast({ title: "Offer deleted" });
           },
apps/dashboard/src/components/payments/offer-dialog.tsx (1)

43-46: Strengthen validation: use dayIntervalOrNeverSchema for repeat.

yup.mixed() weakens validation. Reuse the shared dayIntervalOrNeverSchema to keep client validation aligned with server-side schema.

-import { offerPriceSchema, offerSchema, userSpecifiedIdSchema, yupRecord } from "@stackframe/stack-shared/dist/schema-fields";
+import { offerPriceSchema, offerSchema, userSpecifiedIdSchema, yupRecord, dayIntervalOrNeverSchema } from "@stackframe/stack-shared/dist/schema-fields";
@@
-        repeat: yup.mixed().optional(),
+        repeat: dayIntervalOrNeverSchema.optional(),
apps/backend/src/lib/payments.tsx (1)

103-140: Existence checks cover user/team; clarify custom semantics.

The guard correctly validates UUID shape and existence for user/team. For custom, intentionally skipping existence checks seems aligned with the “opaque identifier” design. If empty strings should be rejected for custom IDs, consider a minimal non-empty check.

-  } else if (options.customerType === "team") {
+  } else if (options.customerType === "team") {-  }
+  } else {
+    // customerType === "custom"
+    if (options.customerId.length === 0) {
+      throw new KnownErrors.CustomerDoesNotExist(options.customerId);
+    }
+  }
apps/dashboard/src/components/data-table/payment-item-table.tsx (3)

63-63: Consider Map over Record for items prop (guideline).

Similar to offers, prefer ES6 Map for items data sources when feasible to match the codebase guideline and avoid prototype pitfalls.

-export function PaymentItemTable({ items }: { items: Record<string, yup.InferType<typeof branchPaymentsSchema>["items"][string]> }) {
-  const data: PaymentItem[] = Object.entries(items).map(([id, item]) => ({
+export function PaymentItemTable({ items }: {
+  items: Map<string, yup.InferType<typeof branchPaymentsSchema>["items"][string]> | Record<string, yup.InferType<typeof branchPaymentsSchema>["items"][string]>
+}) {
+  const entries = items instanceof Map ? [...items.entries()] : Object.entries(items);
+  const data: PaymentItem[] = entries.map(([id, item]) => ({
     id,
     ...item,
   }));

159-164: Tighten validation for Customer ID by type and add guard.

For user/team, validate UUID to reduce avoidable roundtrips; for custom, accept any non-empty string. Also guard undefined customerType (if any).

-  const schema = yup.object({
-    customerId: yup.string().defined().label("Customer ID"),
+  const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
+  const schema = yup.object({
+    customerId: (customerType === "custom"
+      ? yup.string().trim().min(1, "ID is required").defined()
+      : yup.string().matches(uuid, "Must be a valid UUID (v4)").defined()
+    ).label("Customer ID"),
     quantity: yup.number().defined().label("Quantity"),
     description: yup.string().optional().label("Description"),
     expiresAt: yup.date().optional().label("Expires At"),
   });
@@
-  const submit = async (values: yup.InferType<typeof schema>) => {
+  const submit = async (values: yup.InferType<typeof schema>) => {
+    if (!customerType) {
+      toast({ title: "Item has no customer type configured", variant: "destructive" });
+      return "prevent-close" as const;
+    }
     const result = await Result.fromPromise(stackAdminApp.createItemQuantityChange({
       ...(customerType === "user" ?
         { userId: values.customerId } :
         customerType === "team" ?
           { teamId: values.customerId } :
           { customCustomerId: values.customerId }
       ),

Also applies to: 166-173


183-189: Optional: handle CustomerDoesNotExist for custom IDs.

If the backend ever validates custom IDs existence, map KnownErrors.CustomerDoesNotExist to a specific message instead of the generic fallback.

-    } else if (result.error instanceof KnownErrors.TeamNotFound) {
+    } else if (result.error instanceof KnownErrors.TeamNotFound) {
       toast({ title: "No team found with the given ID", variant: "destructive" });
+    } else if (result.error instanceof KnownErrors.CustomerDoesNotExist) {
+      toast({ title: "No custom customer found with the given ID", variant: "destructive" });
packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts (1)

515-522: Minor: factor id-container mapping into a helper for reuse and exhaustiveness.

A small helper improves readability and enables a never-check for future union expansions.

-    await this._interface.updateItemQuantity(
-      { itemId: options.itemId, ...("userId" in options ? { userId: options.userId } : ("teamId" in options ? { teamId: options.teamId } : { customCustomerId: options.customCustomerId })) },
+    const idContainer =
+      "userId" in options ? { userId: options.userId } :
+      "teamId" in options ? { teamId: options.teamId } :
+      "customCustomerId" in options ? { customCustomerId: options.customCustomerId } :
+      ((): never => { throw new Error("Invalid options"); })();
+    await this._interface.updateItemQuantity(
+      { itemId: options.itemId, ...idContainer },
       {
         delta: options.quantity,
         expires_at: options.expiresAt,
         description: options.description,
       }
     );
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 509762d and 93fb85a.

📒 Files selected for processing (34)
  • apps/backend/prisma/migrations/20250821175509_test_mode_subscriptions/migration.sql (1 hunks)
  • apps/backend/prisma/schema.prisma (3 hunks)
  • apps/backend/src/app/api/latest/internal/payments/test-mode-purchase-session/route.tsx (1 hunks)
  • apps/backend/src/app/api/latest/payments/purchases/create-purchase-url/route.ts (3 hunks)
  • apps/backend/src/app/api/latest/payments/purchases/purchase-session/route.tsx (4 hunks)
  • apps/backend/src/app/api/latest/payments/purchases/validate-code/route.ts (3 hunks)
  • apps/backend/src/lib/payments.tsx (4 hunks)
  • apps/backend/src/lib/stripe.tsx (2 hunks)
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/items/page-client.tsx (1 hunks)
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/items/page.tsx (1 hunks)
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offers/page-client.tsx (1 hunks)
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offers/page.tsx (1 hunks)
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/page-client.tsx (2 hunks)
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx (2 hunks)
  • apps/dashboard/src/app/(main)/purchase/[code]/page-client.tsx (7 hunks)
  • apps/dashboard/src/app/(main)/purchase/return/page-client.tsx (3 hunks)
  • apps/dashboard/src/app/(main)/purchase/return/page.tsx (2 hunks)
  • apps/dashboard/src/components/data-table/payment-item-table.tsx (4 hunks)
  • apps/dashboard/src/components/data-table/payment-offer-table.tsx (3 hunks)
  • apps/dashboard/src/components/dialog-opener.tsx (1 hunks)
  • apps/dashboard/src/components/form-fields/day-interval-selector-field.tsx (2 hunks)
  • apps/dashboard/src/components/payments/included-item-editor.tsx (1 hunks)
  • apps/dashboard/src/components/payments/item-dialog.tsx (1 hunks)
  • apps/dashboard/src/components/payments/offer-dialog.tsx (1 hunks)
  • apps/dashboard/src/components/payments/price-editor.tsx (1 hunks)
  • apps/dashboard/src/lib/utils.tsx (2 hunks)
  • apps/dashboard/tailwind.config.ts (1 hunks)
  • apps/e2e/tests/backend/endpoints/api/v1/payments/purchase-session.test.ts (3 hunks)
  • apps/e2e/tests/backend/endpoints/api/v1/payments/validate-code.test.ts (1 hunks)
  • packages/stack-shared/src/config/schema.ts (2 hunks)
  • packages/stack-shared/src/interface/admin-interface.ts (1 hunks)
  • packages/stack-shared/src/schema-fields.ts (3 hunks)
  • packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts (1 hunks)
  • packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (2)
  • packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts
  • apps/backend/src/app/api/latest/payments/purchases/create-purchase-url/route.ts
🧰 Additional context used
📓 Path-based instructions (5)
**/*.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

Prefer ES6 Map over Record where feasible

Files:

  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offers/page.tsx
  • apps/dashboard/tailwind.config.ts
  • apps/dashboard/src/lib/utils.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offers/page-client.tsx
  • apps/e2e/tests/backend/endpoints/api/v1/payments/validate-code.test.ts
  • packages/stack-shared/src/config/schema.ts
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/items/page-client.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx
  • apps/dashboard/src/components/payments/offer-dialog.tsx
  • apps/dashboard/src/components/form-fields/day-interval-selector-field.tsx
  • apps/dashboard/src/components/payments/item-dialog.tsx
  • apps/dashboard/src/components/payments/price-editor.tsx
  • apps/backend/src/app/api/latest/internal/payments/test-mode-purchase-session/route.tsx
  • apps/backend/src/lib/stripe.tsx
  • apps/dashboard/src/components/dialog-opener.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/items/page.tsx
  • apps/backend/src/app/api/latest/payments/purchases/purchase-session/route.tsx
  • apps/dashboard/src/app/(main)/purchase/[code]/page-client.tsx
  • packages/stack-shared/src/interface/admin-interface.ts
  • apps/dashboard/src/app/(main)/purchase/return/page.tsx
  • packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts
  • apps/dashboard/src/components/payments/included-item-editor.tsx
  • apps/backend/src/app/api/latest/payments/purchases/validate-code/route.ts
  • apps/dashboard/src/app/(main)/purchase/return/page-client.tsx
  • apps/e2e/tests/backend/endpoints/api/v1/payments/purchase-session.test.ts
  • packages/stack-shared/src/schema-fields.ts
  • apps/dashboard/src/components/data-table/payment-offer-table.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/page-client.tsx
  • apps/dashboard/src/components/data-table/payment-item-table.tsx
  • apps/backend/src/lib/payments.tsx
apps/e2e/**

📄 CodeRabbit inference engine (CLAUDE.md)

Always add new E2E tests when you change the API or SDK interface

Files:

  • apps/e2e/tests/backend/endpoints/api/v1/payments/validate-code.test.ts
  • apps/e2e/tests/backend/endpoints/api/v1/payments/purchase-session.test.ts
**/*.test.{ts,tsx,js,jsx}

📄 CodeRabbit inference engine (CLAUDE.md)

In tests, prefer .toMatchInlineSnapshot where possible

Files:

  • apps/e2e/tests/backend/endpoints/api/v1/payments/validate-code.test.ts
  • apps/e2e/tests/backend/endpoints/api/v1/payments/purchase-session.test.ts
apps/backend/src/app/api/latest/**

📄 CodeRabbit inference engine (CLAUDE.md)

Place backend API route handlers under /apps/backend/src/app/api/latest and follow RESTful, resource-based paths (auth, users, teams, oauth-providers)

Files:

  • apps/backend/src/app/api/latest/internal/payments/test-mode-purchase-session/route.tsx
  • 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/**/*.ts

📄 CodeRabbit inference engine (CLAUDE.md)

Use the custom route handler system in the backend for consistent API responses

Files:

  • apps/backend/src/app/api/latest/payments/purchases/validate-code/route.ts
🧬 Code graph analysis (18)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offers/page.tsx (2)
apps/dashboard/src/lib/utils.tsx (1)
  • devFeaturesEnabledForProject (20-29)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offers/page-client.tsx (1)
  • PageClient (9-33)
apps/dashboard/src/lib/utils.tsx (2)
packages/stack-shared/src/utils/json.tsx (1)
  • parseJson (72-74)
apps/dashboard/src/lib/env.tsx (1)
  • getPublicEnvVar (49-59)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offers/page-client.tsx (5)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/items/page-client.tsx (1)
  • PageClient (10-36)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/use-admin-app.tsx (1)
  • useAdminApp (27-34)
apps/dashboard/src/components/dialog-opener.tsx (1)
  • DialogOpener (14-27)
apps/dashboard/src/components/payments/offer-dialog.tsx (1)
  • OfferDialog (30-117)
apps/dashboard/src/components/data-table/payment-offer-table.tsx (1)
  • PaymentOfferTable (51-64)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/items/page-client.tsx (5)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offers/page-client.tsx (1)
  • PageClient (9-33)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/use-admin-app.tsx (1)
  • useAdminApp (27-34)
apps/dashboard/src/components/dialog-opener.tsx (1)
  • DialogOpener (14-27)
apps/dashboard/src/components/payments/item-dialog.tsx (1)
  • ItemDialog (30-100)
apps/dashboard/src/components/data-table/payment-item-table.tsx (1)
  • PaymentItemTable (63-76)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx (1)
docs/src/components/icons.tsx (1)
  • Box (334-338)
apps/dashboard/src/components/payments/offer-dialog.tsx (5)
packages/stack-shared/src/schema-fields.ts (4)
  • offerSchema (561-579)
  • userSpecifiedIdSchema (426-426)
  • yupRecord (283-322)
  • offerPriceSchema (555-560)
apps/dashboard/src/components/form-dialog.tsx (1)
  • FormDialog (53-140)
apps/dashboard/src/components/form-fields.tsx (3)
  • InputField (59-97)
  • SelectField (229-266)
  • CheckboxField (268-303)
apps/dashboard/src/components/payments/price-editor.tsx (1)
  • PriceEditorField (18-78)
apps/dashboard/src/components/payments/included-item-editor.tsx (1)
  • IncludedItemEditorField (18-88)
apps/dashboard/src/components/payments/item-dialog.tsx (5)
packages/stack-shared/src/config/schema.ts (1)
  • branchPaymentsSchema (116-147)
packages/stack-shared/src/schema-fields.ts (2)
  • userSpecifiedIdSchema (426-426)
  • dayIntervalOrNeverSchema (421-421)
apps/dashboard/src/components/form-dialog.tsx (1)
  • FormDialog (53-140)
apps/dashboard/src/components/form-fields.tsx (2)
  • InputField (59-97)
  • SelectField (229-266)
apps/dashboard/src/components/form-fields/day-interval-selector-field.tsx (1)
  • DayIntervalSelectorField (12-67)
apps/dashboard/src/components/payments/price-editor.tsx (2)
apps/dashboard/src/components/form-fields.tsx (1)
  • InputField (59-97)
apps/dashboard/src/components/form-fields/day-interval-selector-field.tsx (1)
  • DayIntervalSelectorField (12-67)
apps/backend/src/app/api/latest/internal/payments/test-mode-purchase-session/route.tsx (8)
apps/backend/src/app/api/latest/payments/purchases/purchase-session/route.tsx (1)
  • POST (9-84)
apps/backend/src/app/api/latest/payments/purchases/validate-code/route.ts (1)
  • POST (12-57)
packages/stack-shared/src/schema-fields.ts (5)
  • yupObject (247-251)
  • adminAuthTypeSchema (483-483)
  • adaptSchema (330-330)
  • yupString (187-190)
  • yupNumber (191-194)
apps/backend/src/app/api/latest/payments/purchases/verification-code-handler.tsx (1)
  • purchaseUrlVerificationCodeHandler (5-19)
packages/stack-shared/src/utils/errors.tsx (2)
  • StatusError (152-261)
  • StackAssertionError (69-85)
apps/backend/src/prisma-client.tsx (1)
  • getPrismaClientForTenancy (51-53)
packages/stack-shared/src/utils/strings.tsx (1)
  • typedToUppercase (30-33)
packages/stack-shared/src/utils/dates.tsx (1)
  • addInterval (197-199)
apps/dashboard/src/components/dialog-opener.tsx (1)
packages/stack-ui/src/components/ui/button.tsx (1)
  • Button (92-92)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/items/page.tsx (2)
apps/dashboard/src/lib/utils.tsx (1)
  • devFeaturesEnabledForProject (20-29)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/items/page-client.tsx (1)
  • PageClient (10-36)
apps/backend/src/app/api/latest/payments/purchases/purchase-session/route.tsx (3)
apps/backend/src/app/api/latest/payments/purchases/verification-code-handler.tsx (1)
  • purchaseUrlVerificationCodeHandler (5-19)
apps/backend/src/lib/tenancies.tsx (1)
  • getTenancy (68-77)
packages/stack-shared/src/utils/errors.tsx (1)
  • StackAssertionError (69-85)
apps/dashboard/src/app/(main)/purchase/[code]/page-client.tsx (4)
packages/stack-shared/src/schema-fields.ts (1)
  • inlineOfferSchema (580-601)
packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts (2)
  • StackAdminApp (29-87)
  • StackAdminApp (95-95)
packages/stack-shared/src/utils/promises.tsx (1)
  • runAsynchronouslyWithAlert (312-328)
packages/stack-shared/src/utils/objects.tsx (1)
  • typedEntries (263-265)
apps/backend/src/app/api/latest/payments/purchases/validate-code/route.ts (3)
apps/backend/src/app/api/latest/payments/purchases/verification-code-handler.tsx (1)
  • purchaseUrlVerificationCodeHandler (5-19)
apps/backend/src/lib/tenancies.tsx (1)
  • getTenancy (68-77)
packages/stack-shared/src/utils/errors.tsx (1)
  • StackAssertionError (69-85)
apps/e2e/tests/backend/endpoints/api/v1/payments/purchase-session.test.ts (2)
apps/e2e/tests/helpers.ts (1)
  • it (10-10)
apps/e2e/tests/backend/backend-helpers.ts (1)
  • niceBackendFetch (107-166)
apps/dashboard/src/components/data-table/payment-offer-table.tsx (5)
packages/stack-shared/src/config/schema.ts (1)
  • branchPaymentsSchema (116-147)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/use-admin-app.tsx (1)
  • useAdminApp (27-34)
packages/stack-ui/src/components/data-table/cells.tsx (1)
  • ActionCell (72-123)
apps/dashboard/src/components/payments/offer-dialog.tsx (1)
  • OfferDialog (30-117)
packages/stack-ui/src/components/action-dialog.tsx (1)
  • ActionDialog (31-135)
apps/dashboard/src/components/data-table/payment-item-table.tsx (6)
packages/stack-shared/src/config/schema.ts (1)
  • branchPaymentsSchema (116-147)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/use-admin-app.tsx (1)
  • useAdminApp (27-34)
packages/stack-ui/src/components/data-table/cells.tsx (1)
  • ActionCell (72-123)
apps/dashboard/src/components/payments/item-dialog.tsx (1)
  • ItemDialog (30-100)
packages/stack-ui/src/components/action-dialog.tsx (1)
  • ActionDialog (31-135)
packages/stack-shared/src/known-errors.tsx (2)
  • KnownErrors (1515-1517)
  • KnownErrors (1519-1636)
apps/backend/src/lib/payments.tsx (6)
apps/backend/src/prisma-client.tsx (1)
  • PrismaClientTransaction (16-16)
apps/backend/src/lib/tenancies.tsx (1)
  • Tenancy (47-47)
packages/stack-shared/src/utils/objects.tsx (1)
  • getOrUndefined (543-545)
packages/stack-shared/src/utils/strings.tsx (1)
  • typedToUppercase (30-33)
packages/stack-shared/src/utils/uuids.tsx (1)
  • isUuid (25-27)
packages/stack-shared/src/known-errors.tsx (2)
  • KnownErrors (1515-1517)
  • KnownErrors (1519-1636)
⏰ 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). (8)
  • GitHub Check: docker
  • GitHub Check: all-good
  • GitHub Check: lint_and_build (latest)
  • GitHub Check: build (22.x)
  • GitHub Check: restart-dev-and-test
  • GitHub Check: setup-tests
  • GitHub Check: build (22.x)
  • GitHub Check: Security Check

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants