Skip to content

[Fix] [Refactor] Implement Base Settings for Stack-Auth Plans and Move Metadata from Stripe Webhook Event to Table#1214

Merged
nams1570 merged 3 commits intodevfrom
implement-plan-pricing
Feb 24, 2026
Merged

[Fix] [Refactor] Implement Base Settings for Stack-Auth Plans and Move Metadata from Stripe Webhook Event to Table#1214
nams1570 merged 3 commits intodevfrom
implement-plan-pricing

Conversation

@nams1570
Copy link
Copy Markdown
Collaborator

@nams1570 nams1570 commented Feb 19, 2026

Context

We're looking at implementing plan pricing. While doing so, we encountered a problem with Stripe.
Problem: when we run a stripe operation (purchase), the product info is encoded as part of the stripe metadata request. Stripe encodes metadata as key-value pairs, and the value has a limit of 500 chars. We do this because once we run the stripe operation, stripe fires a webhook event which is caught by our stripe webhook handler syncStripeSubscriptions. This gets the stripe metadata info from the event and then updates our db in prisma.

Summary of Changes

We add a ProductVersion table and only pass the productVersionId via stripe metadata instead of the whole product json. This productVersionId is created by hashing the productJson. Since the same product may be ordered differently without being intrinsically different, we add a helper function for ensuring a canonical order to the json. We also pass tenancy id and product id to the table.
Since there are existing subscriptions which used to pass the productJson via metadata, we ensure backwards compatibility.

Summary by CodeRabbit

  • New Features

    • Stable product versioning for purchases and improved product metadata resolution during payments.
    • Updated pricing catalog: "Team Plans" → "Team" and "Extra Admins" → "Extra Seats" with adjusted price and included quantities; plan limits standardized.
  • Bug Fixes

    • Sanitized subscription period dates to avoid edge-case period errors.
  • Chores

    • Database schema migration to support product version tracking.

Copilot AI review requested due to automatic review settings February 19, 2026 03:36
@vercel
Copy link
Copy Markdown

vercel Bot commented Feb 19, 2026

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

Project Deployment Actions Updated (UTC)
stack-backend Ready Ready Preview, Comment Feb 19, 2026 9:21pm
stack-dashboard Ready Ready Preview, Comment Feb 19, 2026 9:21pm
stack-demo Ready Ready Preview, Comment Feb 19, 2026 9:21pm
stack-docs Ready Ready Preview, Comment Feb 19, 2026 9:21pm

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Feb 19, 2026

📝 Walkthrough

Walkthrough

Adds persistent product versioning: new ProductVersion table and Prisma model, deterministic product-version ID generation/upsert/get utilities, and propagates productVersionId through Stripe flows (purchase/switch/webhooks) while sanitizing Stripe period dates and updating seed/plan constants to ITEM_IDS/PLAN_LIMITS.

Changes

Cohort / File(s) Summary
Database schema & migration
apps/backend/prisma/migrations/20260218194816_add_product_versions/migration.sql, apps/backend/prisma/schema.prisma
Creates ProductVersion table/model with composite PK (tenancyId, productVersionId) and columns productId, productJson, createdAt.
Product version utilities & tests
apps/backend/src/lib/product-versions.tsx
New canonical JSON stringify, computeProductVersionId (SHA-256 → base64), upsertProductVersion, getProductVersion, and vitest tests validating deterministic hashing and ordering.
Stripe helper: resolve & sanitization
apps/backend/src/lib/stripe.tsx
Adds sanitizeStripePeriodDates, StripeMetadataProduct type, resolveProductFromStripeMetadata (lookup by productVersionId with legacy JSON fallback), updates sync logic and tests for metadata resolution and error contexts.
Purchase/session & webhook flows
apps/backend/src/app/api/latest/payments/purchases/purchase-session/route.tsx, apps/backend/src/app/api/latest/integrations/stripe/webhooks/route.tsx
Replace embedded product JSON in Stripe metadata with productVersionId; call upsertProductVersion during purchase/session flows; use resolver in webhooks; adjust product displayName handling.
Subscription switch flow
apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/switch/route.ts
Upserts product version on switch, stores productVersionId in metadata, and uses sanitizeStripePeriodDates for period timestamps in create/update subscription flows.
Plans & seed data
packages/stack-shared/src/plans.ts, apps/backend/prisma/seed.ts
Adds shared plan constants/types (UNLIMITED, ITEM_IDS, PLAN_LIMITS, types) and refactors seed products to use ITEM_IDS/PLAN_LIMITS, renaming/adjusting products and includedItems.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant AppServer as "App Server\n(purchase-session / switch / webhook)"
  participant ProductSvc as "ProductVersion Service\n(upsert/get)"
  participant DB as "Database\n(ProductVersion table)"
  participant Stripe as "Stripe API"

  Client->>AppServer: Initiate purchase or switch (includes product JSON)
  AppServer->>ProductSvc: computeProductVersionId & upsertProductVersion(tenancyId, productId, productJson)
  ProductSvc->>DB: UPSERT ProductVersion (tenancyId, productVersionId, productJson)
  DB-->>ProductSvc: OK (productVersionId)
  ProductSvc-->>AppServer: return productVersionId
  AppServer->>Stripe: create/update Checkout/Subscription with metadata.productVersionId
  Stripe-->>AppServer: webhook event (contains metadata.productVersionId)
  AppServer->>ProductSvc: getProductVersion(tenancyId, productVersionId)
  ProductSvc->>DB: SELECT ProductVersion
  DB-->>ProductSvc: productJson
  ProductSvc-->>AppServer: productJson used for fulfillment
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • upgrade/downgrade plans #1087: Modifies the subscription switch route to upsert and attach productVersionId in Stripe metadata (closely related).
  • fix stripe failing webhook #1102: Changes Stripe metadata parsing and product resolution in apps/backend/src/lib/stripe.tsx (overlaps productVersionId handling).
  • Payments UX update #863: Prior changes to Stripe/payment handling and routes that touch purchase-session/webhook flows and metadata parsing.

Poem

🐇 I hashed each product snug and tight,

Turned messy JSON into tidy light,
A version ID to carry through the night,
Upserted, fetched, and kept things right,
Hop—now purchases hum with delight. 🎉

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main changes: implementing base settings for Stack-Auth plans and moving product metadata from Stripe webhooks to a database table.
Description check ✅ Passed The description clearly explains the problem (Stripe metadata character limits), solution (ProductVersion table with hashing), and includes backward compatibility considerations.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch implement-plan-pricing

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

Comment @coderabbitai help to get the list of available commands and usage tips.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Feb 19, 2026

Greptile Summary

This PR addresses Stripe's 500-character metadata limit by implementing a ProductVersion table to store product JSON separately. Instead of passing the full product JSON through Stripe metadata, the code now:

  1. Hashes product JSON using SHA-256 of canonically-ordered JSON to create a productVersionId
  2. Stores the full product JSON in the new ProductVersion table keyed by (tenancyId, productVersionId)
  3. Passes only the productVersionId (a short hash) through Stripe metadata
  4. Retrieves product JSON from the table when processing webhook events

Key improvements:

  • Solves Stripe metadata size limitation for complex product configurations
  • Maintains backward compatibility with existing subscriptions that have product JSON in metadata
  • Adds canonical JSON serialization to ensure consistent hashing regardless of property order
  • Includes comprehensive test coverage for the new helper functions
  • Introduces base plan settings (free, team, growth) with configurable limits for seats, auth users, emails, etc.

Backward compatibility:
The webhook handler and syncStripeSubscriptions check for productVersionId first, then fall back to metadata.product or metadata.offer for older subscriptions.

Confidence Score: 5/5

  • This PR is safe to merge with comprehensive backward compatibility and thorough implementation
  • The implementation is well-architected with proper error handling, backward compatibility for existing subscriptions, comprehensive test coverage, and follows established patterns. The database migration is safe (only adds a new table), and all code paths handle both new productVersionId and legacy product JSON metadata.
  • No files require special attention

Important Files Changed

Filename Overview
apps/backend/src/lib/product-versions.tsx New helper library for canonical JSON serialization, version ID computation, and ProductVersion CRUD operations with comprehensive tests
apps/backend/src/lib/stripe.tsx Updated syncStripeSubscriptions to retrieve product JSON from ProductVersion table via productVersionId with backward compatibility for old subscriptions
apps/backend/src/app/api/latest/integrations/stripe/webhooks/route.tsx Updated webhook handler to retrieve product JSON via productVersionId for one-time purchases with fallback to metadata.product
apps/backend/src/app/api/latest/payments/purchases/purchase-session/route.tsx Updated to upsert ProductVersion and pass productVersionId to Stripe metadata for subscriptions and payment intents
packages/stack-shared/src/plans.ts New module defining base settings for Stack Auth plans with item IDs and limits for free, team, and growth tiers

Sequence Diagram

sequenceDiagram
    participant Client
    participant API as Purchase Session API
    participant PV as ProductVersion Helper
    participant DB as ProductVersion Table
    participant Stripe
    participant Webhook as Stripe Webhook Handler

    Client->>API: POST /purchase-session (product JSON, priceId)
    API->>PV: upsertProductVersion(productJson)
    PV->>PV: canonicalJsonStringify(productJson)
    PV->>PV: computeProductVersionId(hash)
    PV->>DB: UPSERT ProductVersion
    DB-->>PV: productVersionId
    PV-->>API: productVersionId
    API->>Stripe: Create subscription/payment intent<br/>(metadata: productVersionId)
    Stripe-->>API: client_secret
    API-->>Client: client_secret
    
    Note over Client,Stripe: Customer completes payment in Stripe

    Stripe->>Webhook: Webhook event (metadata: productVersionId)
    Webhook->>PV: getProductVersion(productVersionId)
    PV->>DB: SELECT ProductVersion
    DB-->>PV: productJson
    PV-->>Webhook: productJson
    Webhook->>DB: UPSERT Subscription/OneTimePurchase<br/>(with full productJson)
    Webhook-->>Stripe: 200 OK
Loading

Last reviewed commit: 71b3666

Copy link
Copy Markdown
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.

9 files reviewed, no comments

Edit Code Review Agent Settings | Greptile

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Introduces centralized plan limits for Stack Auth pricing tiers and refactors Stripe purchase/subscription flows to avoid exceeding Stripe metadata size limits by storing product JSON in a new ProductVersion table keyed by a deterministic hash.

Changes:

  • Add ProductVersion persistence + hashing utilities and switch Stripe metadata to pass productVersionId instead of full product JSON (with backward compatibility for older subscriptions).
  • Add sanitizeStripePeriodDates to mitigate invalid Stripe-mock fixture timestamps when syncing/upserting subscriptions.
  • Add shared plan limits/item IDs and update backend seeding to use them for plan products and included items.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
packages/stack-shared/src/plans.ts Adds shared plan limit constants and item IDs for pricing tiers.
apps/backend/src/lib/stripe.tsx Uses productVersionId lookup during subscription sync; adds Stripe-mock date sanitization.
apps/backend/src/lib/product-versions.tsx New helper for canonical JSON hashing + Prisma upsert/get for ProductVersion rows (with inline tests).
apps/backend/src/app/api/latest/payments/purchases/purchase-session/route.tsx Creates ProductVersion records and sends only productVersionId in Stripe metadata.
apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/switch/route.ts Stores ProductVersion and moves subscription metadata to productVersionId; uses date sanitization on returned Stripe periods.
apps/backend/src/app/api/latest/integrations/stripe/webhooks/route.tsx Resolves one-time purchase product via productVersionId when present.
apps/backend/prisma/seed.ts Seeds plan products/items using the new shared plan constants and item IDs.
apps/backend/prisma/schema.prisma Adds ProductVersion model.
apps/backend/prisma/migrations/20260218194816_add_product_versions/migration.sql Creates the ProductVersion table in SQL migration.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread apps/backend/src/app/api/latest/integrations/stripe/webhooks/route.tsx Outdated
Comment thread apps/backend/src/lib/stripe.tsx Outdated
Comment thread apps/backend/src/lib/stripe.tsx Outdated
Copy link
Copy Markdown
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: 5

Caution

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

⚠️ Outside diff range comments (1)
apps/backend/src/app/api/latest/integrations/stripe/webhooks/route.tsx (1)

251-288: ⚠️ Potential issue | 🟠 Major

payment_intent.payment_failed handler doesn't resolve productVersionId — product name in failure emails will always be "Purchase" for new-style payment intents.

The payment_intent.succeeded path was updated to prefer productVersionId, but the .payment_failed branch at Line 273 still falls back to JSON.parse(metadata.product || "{}"). Since purchase-session/route.tsx now writes only productVersionId into Stripe metadata (no product field), metadata.product will be undefined for all new payment intents. The fallback evaluates to {}, so productName becomes "Purchase".

🐛 Proposed fix — apply the same version-aware resolution as the success path
+    const productVersionId = metadata.productVersionId as string | undefined;
+    let product: unknown;
+    if (productVersionId) {
+      product = (await getProductVersion({ prisma, tenancyId: tenancy.id, productVersionId })).productJson;
+    } else {
-    const product = JSON.parse(metadata.product || "{}");
+      try {
+        product = JSON.parse(metadata.product || "{}");
+      } catch {
+        product = {};
+      }
+    }
🧹 Nitpick comments (5)
apps/backend/src/app/api/latest/integrations/stripe/webhooks/route.tsx (1)

187-191: Type assertion and boolean truthiness check both violate coding guidelines.

Line 188 uses as string | undefined to widen the type, and Line 189 uses a truthy check (productVersionId ? …) instead of an explicit null/undefinedness check. Per coding guidelines, avoid as/type-casts and prefer foo == null style checks.

♻️ Suggested rewrite
-    const productVersionId = metadata.productVersionId as string | undefined;
-    const product = productVersionId
-      ? (await getProductVersion({ prisma, tenancyId: tenancy.id, productVersionId })).productJson
-      : JSON.parse(metadata.product || "{}");
+    const productVersionId = metadata.productVersionId || undefined;   // coerce "" → undefined
+    let product: unknown;
+    if (productVersionId != null) {
+      product = (await getProductVersion({ prisma, tenancyId: tenancy.id, productVersionId })).productJson;
+    } else {
+      try {
+        product = JSON.parse(metadata.product ?? "{}");
+      } catch {
+        product = {};
+      }
+    }

As per coding guidelines: "Do NOT use as/any/type casts to bypass the type system" and "Use explicit null/undefinedness checks instead of boolean checks."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/backend/src/app/api/latest/integrations/stripe/webhooks/route.tsx`
around lines 187 - 191, Remove the type assertion and replace the truthy check
with an explicit null/undefined check: stop using "as string | undefined" on
metadata.productVersionId and instead read it directly (e.g., const
productVersionId = metadata.productVersionId) and test it with productVersionId
== null to decide the branch; when productVersionId is not null/undefined call
getProductVersion({ prisma, tenancyId: tenancy.id, productVersionId }) to obtain
productJson, otherwise parse metadata.product with JSON.parse. Ensure you
reference the existing symbols productVersionId, metadata, getProductVersion,
and product to locate and update the logic.
apps/backend/src/lib/product-versions.tsx (2)

53-58: productJson as object type assertion needs a justification comment.

Per coding guidelines, any as cast must include a comment explaining why the type system fails. Prisma requires object for JSON fields, but productJson is typed as unknown, making the cast necessary. Add a comment:

-      productJson: options.productJson as object,
+      // Prisma requires `object` for JSONB fields; options.productJson is validated
+      // to be a serializable JSON value by canonicalJsonStringify at the call site.
+      productJson: options.productJson as object,

As per coding guidelines: "Avoid using the any type; when you must use it, leave a comment explaining why the type system fails."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/backend/src/lib/product-versions.tsx` around lines 53 - 58, The code is
using a direct cast "productJson as object" without justification; update the
create payload in product-versions.tsx (the create: { ... productJson:
options.productJson as object, ... } block) to include a brief inline comment
explaining the cast: state that Prisma's JSON/Json type requires plain object
but options.productJson is typed as unknown (or comes from untyped input), so
the explicit cast is necessary; place the comment immediately alongside the cast
and do not change the runtime behavior.

10-22: canonicalJsonStringify declared as returning string but returns undefined for undefined input.

JSON.stringify(undefined) returns undefined in JavaScript. If obj is undefined, the function silently returns undefined instead of a string, and computeProductVersionId would then call crypto.createHash("sha256").update(undefined) which throws a TypeError. The return type string is an incorrect promise.

♻️ Proposed fix
-export function canonicalJsonStringify(obj: unknown): string {
-  return JSON.stringify(obj, (_, value) => {
+export function canonicalJsonStringify(obj: Exclude<unknown, undefined>): string {
+  const result = JSON.stringify(obj, (_, value) => {
     if (value && typeof value === "object" && !Array.isArray(value)) {
       return Object.keys(value)
         .sort()
         .reduce((sorted: Record<string, unknown>, key) => {
           sorted[key] = value[key];
           return sorted;
         }, {});
     }
     return value;
   });
+  if (result === undefined) {
+    throw new Error("canonicalJsonStringify: cannot serialize undefined");
+  }
+  return result;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/backend/src/lib/product-versions.tsx` around lines 10 - 22,
canonicalJsonStringify currently can return undefined for an undefined input
which breaks callers like computeProductVersionId that pass the result to
crypto.createHash(...).update(...); fix by handling undefined explicitly in
canonicalJsonStringify (e.g., if obj === undefined return a safe JSON string
like "null" or another agreed sentinel) before calling JSON.stringify with the
replacer logic, ensuring the function always returns a string; reference
canonicalJsonStringify and the downstream crypto.createHash(...).update usage so
the change prevents a TypeError when obj is undefined.
apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/switch/route.ts (1)

209-229: Subscription update path uses pre-update item dates instead of the post-update response.

existingItem is retrieved before stripe.subscriptions.update(...). The updatedSubscription object already contains the authoritative current_period_start/current_period_end after the plan switch. While Stripe keeps the same billing cycle during a proration-based switch (so the values are typically identical), using the response directly is more explicit and resilient to future Stripe behavior changes.

♻️ Suggested refactor
-      const sanitizedUpdateDates = sanitizeStripePeriodDates(existingItem.current_period_start, existingItem.current_period_end);
+      const updatedItem = updatedSubscription.items.data[0]
+        ?? throwErr("Updated Stripe subscription has no items");
+      const sanitizedUpdateDates = sanitizeStripePeriodDates(
+        updatedItem.current_period_start,
+        updatedItem.current_period_end,
+      );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/backend/src/app/api/latest/payments/products/`[customer_type]/[customer_id]/switch/route.ts
around lines 209 - 229, The subscription update is using
existingItem.current_period_start/current_period_end (pre-update) instead of the
authoritative values from the Stripe response; change the call that computes
sanitizedUpdateDates to use updatedSubscription.current_period_start and
updatedSubscription.current_period_end (i.e., call
sanitizeStripePeriodDates(updatedSubscription.current_period_start,
updatedSubscription.current_period_end)) and then pass those sanitized start/end
values into prisma.subscription.update for currentPeriodStart and
currentPeriodEnd so the DB reflects Stripe's post-update periods.
apps/backend/src/lib/stripe.tsx (1)

137-144: Two type assertions violate coding guidelines.

Line 137: subscription.metadata.productVersionId as string | undefined and Line 144: version.productJson as InputJsonValue bypass the type system without explanation.

For Line 137, Stripe.Subscription["metadata"] types all values as string, so the key may be absent at runtime but TypeScript doesn't model that. A safe alternative:

-    const productVersionId = subscription.metadata.productVersionId as string | undefined;
+    const productVersionId = subscription.metadata.productVersionId || undefined;

For Line 144, a comment explaining why the cast is needed (Prisma's InputJsonValue can't be directly inferred from unknown) is required per guidelines.

-      productJson = version.productJson as InputJsonValue;
+      // productJson is `unknown` from getProductVersion but Prisma's upsert/create
+      // requires InputJsonValue; a runtime-invalid value would be caught by Prisma validation.
+      productJson = version.productJson as InputJsonValue;

As per coding guidelines: "Do NOT use as/any/type casts to bypass the type system unless explicitly approved; … leave a comment explaining why the type system fails."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/backend/src/lib/stripe.tsx` around lines 137 - 144, Replace the two
unchecked casts with safe checks: stop using
"subscription.metadata.productVersionId as string | undefined" and instead read
productVersionId directly then guard it with a runtime type check (e.g., const
productVersionId = subscription.metadata.productVersionId; if (typeof
productVersionId === 'string') { ... }) before calling getProductVersion so you
don’t bypass the TS system; for version.productJson, either validate/transform
the returned value into a proper InputJsonValue with a small
type‑guard/validator function and assign that validated value to productJson, or
if you must cast, add a single-line comment on the same line explaining
precisely why the type system cannot infer the shape (e.g., Prisma returns
unknown for JSON) and reference the validator or approval that justifies the
cast (use the symbol version.productJson and type InputJsonValue in the
comment).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@apps/backend/prisma/migrations/20260218194816_add_product_versions/migration.sql`:
- Around line 2-10: Add a foreign key constraint on ProductVersion.tenancyId
referencing the Tenancy table (Tenancy.tenancyId) to prevent orphaned
ProductVersion rows; update the CREATE TABLE "ProductVersion" statement to
include a CONSTRAINT (e.g. ProductVersion_tenancyId_fkey) that references
Tenancy("tenancyId") and include an appropriate delete behavior such as ON
DELETE CASCADE (or ON DELETE RESTRICT if you prefer to prevent deletions).

In `@apps/backend/prisma/schema.prisma`:
- Around line 1006-1014: Add a proper foreign-key relation from ProductVersion
to the Tenancy model so tenancy deletes cascade: in the ProductVersion model add
a relation field (e.g., tenancy Tenancy `@relation`(fields: [tenancyId],
references: [id], onDelete: Cascade)) while keeping the existing tenancyId
String `@db.Uuid` key and the composite primary key @@id([tenancyId,
productVersionId]); after updating the schema, generate and apply a migration to
add the FK constraint to the database.

In `@apps/backend/prisma/seed.ts`:
- Around line 203-207: Review and either confirm the intentional mapping or make
the naming consistent: inspect ITEM_IDS.seats and the seed entry where
displayName is set to "Dashboard Admins" in seed.ts; if the domain term is
"seats" used by APIs/quotas, change the displayName to "Seats" (or update
ITEM_IDS.seats to a more explicit identifier like dashboardAdmins) so API
identifiers and UI labels match, or add a clear inline comment explaining that
ITEM_IDS.seats intentionally maps to dashboard admin slots to prevent future
accidental renames.

In `@apps/backend/src/lib/product-versions.tsx`:
- Around line 38-63: upsertProductVersion can store a stale productId when two
different products have identical productJson because
computeProductVersionId(productJson) is used as the sole upsert key and update:
{} is a no-op; fix by either (A) making the version id unique per product
identity by including options.productId in the hash (update
computeProductVersionId signature/implementation and call site in
upsertProductVersion so productVersionId =
computeProductVersionId(options.productJson, options.productId)), or (B)
explicitly treat ProductVersion.productId as informational/first-wins by
documenting this behavior and leaving the upsert as-is (or, if you prefer to
ensure the latest product wins, change the upsert update to update: { productId:
options.productId } so productId is overwritten on collisions); update the
codepaths that call computeProductVersionId and the productVersion.upsert call
accordingly to reflect your chosen approach.

In `@apps/backend/src/lib/stripe.tsx`:
- Line 5: The file imports InputJsonValue from the private module
"@prisma/client/runtime/client"; replace that with the public Prisma API by
importing Prisma from "@prisma/client" and use Prisma.InputJsonValue wherever
InputJsonValue is referenced (or import the public InputJsonValue type from
"@prisma/client" if you prefer). Update the import line that currently reads
import { InputJsonValue } from "@prisma/client/runtime/client" to use the public
export and adjust any type annotations to Prisma.InputJsonValue (or the public
InputJsonValue) to match the rest of the codebase.

---

Nitpick comments:
In `@apps/backend/src/app/api/latest/integrations/stripe/webhooks/route.tsx`:
- Around line 187-191: Remove the type assertion and replace the truthy check
with an explicit null/undefined check: stop using "as string | undefined" on
metadata.productVersionId and instead read it directly (e.g., const
productVersionId = metadata.productVersionId) and test it with productVersionId
== null to decide the branch; when productVersionId is not null/undefined call
getProductVersion({ prisma, tenancyId: tenancy.id, productVersionId }) to obtain
productJson, otherwise parse metadata.product with JSON.parse. Ensure you
reference the existing symbols productVersionId, metadata, getProductVersion,
and product to locate and update the logic.

In
`@apps/backend/src/app/api/latest/payments/products/`[customer_type]/[customer_id]/switch/route.ts:
- Around line 209-229: The subscription update is using
existingItem.current_period_start/current_period_end (pre-update) instead of the
authoritative values from the Stripe response; change the call that computes
sanitizedUpdateDates to use updatedSubscription.current_period_start and
updatedSubscription.current_period_end (i.e., call
sanitizeStripePeriodDates(updatedSubscription.current_period_start,
updatedSubscription.current_period_end)) and then pass those sanitized start/end
values into prisma.subscription.update for currentPeriodStart and
currentPeriodEnd so the DB reflects Stripe's post-update periods.

In `@apps/backend/src/lib/product-versions.tsx`:
- Around line 53-58: The code is using a direct cast "productJson as object"
without justification; update the create payload in product-versions.tsx (the
create: { ... productJson: options.productJson as object, ... } block) to
include a brief inline comment explaining the cast: state that Prisma's
JSON/Json type requires plain object but options.productJson is typed as unknown
(or comes from untyped input), so the explicit cast is necessary; place the
comment immediately alongside the cast and do not change the runtime behavior.
- Around line 10-22: canonicalJsonStringify currently can return undefined for
an undefined input which breaks callers like computeProductVersionId that pass
the result to crypto.createHash(...).update(...); fix by handling undefined
explicitly in canonicalJsonStringify (e.g., if obj === undefined return a safe
JSON string like "null" or another agreed sentinel) before calling
JSON.stringify with the replacer logic, ensuring the function always returns a
string; reference canonicalJsonStringify and the downstream
crypto.createHash(...).update usage so the change prevents a TypeError when obj
is undefined.

In `@apps/backend/src/lib/stripe.tsx`:
- Around line 137-144: Replace the two unchecked casts with safe checks: stop
using "subscription.metadata.productVersionId as string | undefined" and instead
read productVersionId directly then guard it with a runtime type check (e.g.,
const productVersionId = subscription.metadata.productVersionId; if (typeof
productVersionId === 'string') { ... }) before calling getProductVersion so you
don’t bypass the TS system; for version.productJson, either validate/transform
the returned value into a proper InputJsonValue with a small
type‑guard/validator function and assign that validated value to productJson, or
if you must cast, add a single-line comment on the same line explaining
precisely why the type system cannot infer the shape (e.g., Prisma returns
unknown for JSON) and reference the validator or approval that justifies the
cast (use the symbol version.productJson and type InputJsonValue in the
comment).

Comment thread apps/backend/prisma/schema.prisma
Comment thread apps/backend/prisma/seed.ts
Comment thread apps/backend/src/lib/product-versions.tsx
Comment thread apps/backend/src/lib/stripe.tsx Outdated
…ipe for subscription

stripe mock gives us invalifd subscription dates which means we dont get the item. so we sanitize it to make it a valid range. In prod, this is unlikely to happen but it will serve as a guard.
Stripe metadata has a character limit of 500.
We used to pass product info (including num of items) into the metadata of the stripe object.
So when we tried to invoke stripe with this metadata, if it was over 500 chars, it would cause stripe to return an error.
This was done because when the stripe webhook event fired, it would send the metadata along with it so our handler could pick it up.
We rework this to only passing an id for use in a new table lookup in the handler.
This decouples the product info from the webhook event.
We keep it backwards compatible because there are existing subscriptions that have the product in the metadata, the same way we kept the offer parsing code for the subscriptions that had offer in the metadata.
The productVersionId is hashed on the productJson to dedup it.
@nams1570 nams1570 force-pushed the implement-plan-pricing branch from 71b3666 to 660dded Compare February 19, 2026 20:21
Copy link
Copy Markdown
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: 2

🧹 Nitpick comments (4)
apps/backend/src/lib/stripe.tsx (1)

20-57: sanitizeStripePeriodDates — good defensive handling for mock data, but document that equal timestamps are also treated as invalid.

The strict < check (line 42) means start === end triggers the fallback too. This is fine for the mock scenario but could surprise callers for legitimate zero-length periods (e.g., immediate cancellation). A brief inline comment would clarify the intent.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/backend/src/lib/stripe.tsx` around lines 20 - 57, The check in
sanitizeStripePeriodDates uses a strict < (if (startDate < endDate)), which
means start === end is considered invalid and triggers the fallback; add a short
inline comment next to that condition (and/or a brief note in the JSDoc) stating
that equal timestamps are intentionally treated as invalid (to cover mock
fixtures and avoid zero-length/ambiguous periods) so callers understand
immediate-zero-length periods will fall back to the default behavior; reference
the sanitizeStripePeriodDates function and the if (startDate < endDate)
condition when you add the comment.
apps/backend/src/lib/product-versions.tsx (1)

57-63: options.productJson as object bypasses the type system.

Per coding guidelines, type casts should be avoided. If productJson is unknown, casting it to object can mask bugs (e.g., if null or a primitive is passed). Consider typing the parameter as Prisma.InputJsonValue or adding a runtime check:

Proposed fix
-    create: {
-      tenancyId: options.tenancyId,
-      productVersionId,
-      productId: options.productId,
-      productJson: options.productJson as object,
-    },
+    create: {
+      tenancyId: options.tenancyId,
+      productVersionId,
+      productId: options.productId,
+      productJson: (options.productJson != null && typeof options.productJson === "object")
+        ? options.productJson as Prisma.InputJsonValue
+        : throwErr(new StackAssertionError("productJson must be a non-null object", { productJson: options.productJson })),
+    },

You'd also need to add the imports:

import type { Prisma } from "@/generated/prisma/client";

As per coding guidelines: "Do NOT use as/any/type casts or anything else to bypass the type system unless you specifically asked the user about it."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/backend/src/lib/product-versions.tsx` around lines 57 - 63, The code in
the create payload is casting options.productJson with "as object", which
bypasses type safety; change the type of the parameter or the property to
Prisma.InputJsonValue (import type { Prisma } from "@/generated/prisma/client")
so productJson is correctly typed, or add a runtime validation that rejects
null/primitives before assigning to the create.productJson field; update the
function signature/parameter type that supplies options.productJson (and remove
the "as object" cast) and ensure create.productJson uses the validated/typed
value.
packages/stack-shared/src/plans.ts (1)

13-32: PlanProductOfferings keys are not derived from ITEM_IDS, allowing silent drift.

If a new entry is added to ITEM_IDS, PlanProductOfferings and every plan in PLAN_LIMITS must be updated manually — TypeScript won't flag a missing field. Deriving the type from ITEM_IDS ties them together:

Proposed refactor
-export type PlanProductOfferings = {
-  seats: number,
-  authUsers: number,
-  emailsPerMonth: number,
-  analyticsTimeoutSeconds: number,
-  analyticsEvents: number,
-};
+export type PlanProductOfferings = {
+  [K in keyof typeof ITEM_IDS]: number;
+};
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/stack-shared/src/plans.ts` around lines 13 - 32,
PlanProductOfferings currently lists keys manually and can drift from ITEM_IDS;
replace the explicit object type with one derived from ITEM_IDS so keys stay in
sync. Update PlanProductOfferings to use the ItemId mapped type (e.g., type
PlanProductOfferings = Record<ItemId, number>) and ensure any usages like
PLAN_LIMITS use this derived type instead of the hardcoded shape; keep ITEM_IDS
and ItemId as-is so the new type maps to the exact keys.
apps/backend/prisma/seed.ts (1)

123-200: Highly repetitive includedItems blocks — consider a helper.

Each plan repeats the same { quantity: …, repeat: "never" as const, expires: "when-purchase-expires" as const } shape for every item. A small helper would reduce boilerplate and prevent copy-paste mistakes if the shape ever changes:

const planItem = (quantity: number) => ({
  quantity,
  repeat: "never" as const,
  expires: "when-purchase-expires" as const,
});

Then each plan becomes:

includedItems: {
  [ITEM_IDS.seats]: planItem(PLAN_LIMITS.free.seats),
  [ITEM_IDS.authUsers]: planItem(PLAN_LIMITS.free.authUsers),
  // ...
},

This is seed data so the repetition is low-risk, but it adds up to ~25 nearly-identical object literals.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/backend/prisma/seed.ts` around lines 123 - 200, The includedItems blocks
repeat the same object shape; create a small helper function (e.g.,
planItem(quantity) or buildPlanItem) that returns { quantity, repeat: "never" as
const, expires: "when-purchase-expires" as const } and replace each inline
literal in the includedItems maps (for keys using ITEM_IDS in the free, team,
growth, and "extra-seats" entries) with calls to that helper (passing
PLAN_LIMITS.* values or 1 for extra seats) so the shape is centralized and less
error-prone.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/backend/src/app/api/latest/integrations/stripe/webhooks/route.tsx`:
- Around line 187-191: The code builds `product` using either `productVersionId`
(via getProductVersion) or JSON.parse(metadata.product || "{}"), which silently
yields an empty object when both sources are absent; change this to validate and
throw instead of defaulting to "{}": in the block where `productVersionId` and
`product` are determined (look for the `productVersionId` variable and the
`product = ...` assignment), if neither `productVersionId` nor
`metadata.product` is present throw a descriptive error (e.g., "Missing
productVersionId and metadata.product for Stripe webhook") so processing fails
fast, otherwise proceed to call getProductVersion or
JSON.parse(metadata.product) as currently implemented.
- Around line 273-276: Duplicate product resolution and a silent "{}" fallback
are present; extract and use a helper (e.g., resolveProductFromMetadata) to
centralize logic used where productVersionId and metadata.product are checked.
Implement resolveProductFromMetadata to accept (prisma, tenancyId, metadata) and
return the parsed product or throw/log on missing/invalid data; replace both the
current inline resolution and the failure-path resolution with calls to this
helper, and ensure it calls getProductVersion(productVersionId) when
productVersionId is present and avoids silently returning "{}" by handling
parsing errors or absent product explicitly.

---

Duplicate comments:
In `@apps/backend/prisma/schema.prisma`:
- Around line 1076-1084: The ProductVersion model lacks a relation to Tenancy so
rows become orphaned when a Tenancy is deleted; add a tenancy field with a
proper Prisma relation to the Tenancy model and specify onDelete: Cascade (e.g.,
tenancy Tenancy `@relation`(fields: [tenancyId], references: [id], onDelete:
Cascade)) and mark tenancyId as the foreign key field so deletions cascade and
maintain referential integrity for productVersionId/tenancyId composite primary
key.

In `@apps/backend/prisma/seed.ts`:
- Around line 203-207: ITEM_IDS.seats currently maps to the ID
"dashboard_admins" but has displayName "Dashboard Admins", causing semantic
mismatch; either rename the key ITEM_IDS.seats to ITEM_IDS.dashboardAdmins (and
update all usages) if the concept is specifically dashboard admin slots, or
change the displayName for ITEM_IDS.seats to "Seats" (and adjust any user-facing
text) so the identifier and displayName are consistent across the DB seed and
codebase; locate the constant ITEM_IDS and the seed entry for ITEM_IDS.seats to
apply the chosen change and ensure all references compile and tests pass.

---

Nitpick comments:
In `@apps/backend/prisma/seed.ts`:
- Around line 123-200: The includedItems blocks repeat the same object shape;
create a small helper function (e.g., planItem(quantity) or buildPlanItem) that
returns { quantity, repeat: "never" as const, expires: "when-purchase-expires"
as const } and replace each inline literal in the includedItems maps (for keys
using ITEM_IDS in the free, team, growth, and "extra-seats" entries) with calls
to that helper (passing PLAN_LIMITS.* values or 1 for extra seats) so the shape
is centralized and less error-prone.

In `@apps/backend/src/lib/product-versions.tsx`:
- Around line 57-63: The code in the create payload is casting
options.productJson with "as object", which bypasses type safety; change the
type of the parameter or the property to Prisma.InputJsonValue (import type {
Prisma } from "@/generated/prisma/client") so productJson is correctly typed, or
add a runtime validation that rejects null/primitives before assigning to the
create.productJson field; update the function signature/parameter type that
supplies options.productJson (and remove the "as object" cast) and ensure
create.productJson uses the validated/typed value.

In `@apps/backend/src/lib/stripe.tsx`:
- Around line 20-57: The check in sanitizeStripePeriodDates uses a strict < (if
(startDate < endDate)), which means start === end is considered invalid and
triggers the fallback; add a short inline comment next to that condition (and/or
a brief note in the JSDoc) stating that equal timestamps are intentionally
treated as invalid (to cover mock fixtures and avoid zero-length/ambiguous
periods) so callers understand immediate-zero-length periods will fall back to
the default behavior; reference the sanitizeStripePeriodDates function and the
if (startDate < endDate) condition when you add the comment.

In `@packages/stack-shared/src/plans.ts`:
- Around line 13-32: PlanProductOfferings currently lists keys manually and can
drift from ITEM_IDS; replace the explicit object type with one derived from
ITEM_IDS so keys stay in sync. Update PlanProductOfferings to use the ItemId
mapped type (e.g., type PlanProductOfferings = Record<ItemId, number>) and
ensure any usages like PLAN_LIMITS use this derived type instead of the
hardcoded shape; keep ITEM_IDS and ItemId as-is so the new type maps to the
exact keys.

Comment thread apps/backend/src/app/api/latest/integrations/stripe/webhooks/route.tsx Outdated
Comment thread apps/backend/src/app/api/latest/integrations/stripe/webhooks/route.tsx Outdated
Some stripe webhook handlers didnt incorporate the fallback to product and even to offer when reading product info from metadata.
This fallback existed because of backwards compatibility.
We create one helper function as a source of truth and reuse it everywhere, with appropriate error handling and fallback.
@nams1570 nams1570 requested a review from N2D4 February 19, 2026 21:18
Copy link
Copy Markdown
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: 2

🧹 Nitpick comments (1)
apps/backend/src/lib/stripe.tsx (1)

118-183: prisma: {} as any in 6 test cases lacks the required explanatory comment.

Per the coding guideline: "Whenever you need to use any, leave a comment explaining why you're using it and how you can be certain that errors would still be flagged."

All six prisma: {} as any instances should include a brief inline comment. Example:

✏️ Proposed fix
-      prisma: {} as any,
+      prisma: {} as any, // productVersionId path is not exercised here; prisma is never called in these test cases
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/backend/src/lib/stripe.tsx` around lines 118 - 183, Update the six test
invocations of resolveProductFromStripeMetadata that pass prisma: {} as any to
include a brief inline comment explaining why any is used (e.g., tests only need
a minimal stub for prisma and we rely on TypeScript elsewhere for type safety)
and how errors will still be caught (e.g., runtime logic is covered by other
tests/E2E or the function validates inputs), so replace each plain prisma: {} as
any with prisma: {} as any /* stub Prisma client for unit test; full typings/E2E
cover runtime behavior */ (or similar concise comment) next to the as any to
satisfy the coding guideline.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/backend/src/lib/stripe.tsx`:
- Around line 57-61: The current fallback computes defaultEnd by calling
defaultEnd.setMonth(defaultEnd.getMonth() + 1) which can overflow on month-end
dates; replace that with a safe month-add routine: capture now's year, month and
day, compute the target month/year (month+1), determine daysInTargetMonth via
new Date(targetYear, targetMonth + 1, 0).getDate(), then construct defaultEnd
with the same time but day = Math.min(now.getDate(), daysInTargetMonth) so the
resulting defaultEnd (used in the return { start: now, end: defaultEnd }) never
rolls into the following month; update the code around the variables now and
defaultEnd accordingly (or extract a small helper like addMonthsSafely(now, 1)).
- Around line 87-93: Import productSchema as a runtime value (not `import type`)
and use it to validate before returning instead of casting; replace the two
unsafe casts around `version.productJson` and the parsed `productString` by
passing the raw value into `productSchema` (e.g.,
`productSchema.parse`/`safeParse`) to ensure it conforms to
`StripeMetadataProduct`, handle validation failures (log and throw or return a
safe fallback) and then return the validated value; references: productSchema,
StripeMetadataProduct, version.productJson, productString,
options.metadata.product, options.metadata.offer.

---

Nitpick comments:
In `@apps/backend/src/lib/stripe.tsx`:
- Around line 118-183: Update the six test invocations of
resolveProductFromStripeMetadata that pass prisma: {} as any to include a brief
inline comment explaining why any is used (e.g., tests only need a minimal stub
for prisma and we rely on TypeScript elsewhere for type safety) and how errors
will still be caught (e.g., runtime logic is covered by other tests/E2E or the
function validates inputs), so replace each plain prisma: {} as any with prisma:
{} as any /* stub Prisma client for unit test; full typings/E2E cover runtime
behavior */ (or similar concise comment) next to the as any to satisfy the
coding guideline.

Comment thread apps/backend/src/lib/stripe.tsx
Comment thread apps/backend/src/lib/stripe.tsx
@nams1570 nams1570 merged commit e9886bc into dev Feb 24, 2026
59 of 64 checks passed
@nams1570 nams1570 deleted the implement-plan-pricing branch February 24, 2026 06:09
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