Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
📝 WalkthroughWalkthroughAdds 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
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
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Greptile SummaryThis PR addresses Stripe's 500-character metadata limit by implementing a
Key improvements:
Backward compatibility: Confidence Score: 5/5
Important Files Changed
Sequence DiagramsequenceDiagram
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
Last reviewed commit: 71b3666 |
There was a problem hiding this comment.
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
ProductVersionpersistence + hashing utilities and switch Stripe metadata to passproductVersionIdinstead of full product JSON (with backward compatibility for older subscriptions). - Add
sanitizeStripePeriodDatesto 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.
There was a problem hiding this comment.
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_failedhandler doesn't resolveproductVersionId— product name in failure emails will always be"Purchase"for new-style payment intents.The
payment_intent.succeededpath was updated to preferproductVersionId, but the.payment_failedbranch at Line 273 still falls back toJSON.parse(metadata.product || "{}"). Sincepurchase-session/route.tsxnow writes onlyproductVersionIdinto Stripe metadata (noproductfield),metadata.productwill beundefinedfor all new payment intents. The fallback evaluates to{}, soproductNamebecomes"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 | undefinedto widen the type, and Line 189 uses a truthy check (productVersionId ? …) instead of an explicit null/undefinedness check. Per coding guidelines, avoidas/type-casts and preferfoo == nullstyle 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 objecttype assertion needs a justification comment.Per coding guidelines, any
ascast must include a comment explaining why the type system fails. Prisma requiresobjectfor JSON fields, butproductJsonis typed asunknown, 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
anytype; 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:canonicalJsonStringifydeclared as returningstringbut returnsundefinedforundefinedinput.
JSON.stringify(undefined)returnsundefinedin JavaScript. Ifobjisundefined, the function silently returnsundefinedinstead of a string, andcomputeProductVersionIdwould then callcrypto.createHash("sha256").update(undefined)which throws aTypeError. The return typestringis 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.
existingItemis retrieved beforestripe.subscriptions.update(...). TheupdatedSubscriptionobject already contains the authoritativecurrent_period_start/current_period_endafter 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 | undefinedand Line 144:version.productJson as InputJsonValuebypass the type system without explanation.For Line 137,
Stripe.Subscription["metadata"]types all values asstring, 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
InputJsonValuecan't be directly inferred fromunknown) 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).
…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.
71b3666 to
660dded
Compare
There was a problem hiding this comment.
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) meansstart === endtriggers 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 objectbypasses the type system.Per coding guidelines, type casts should be avoided. If
productJsonisunknown, casting it toobjectcan mask bugs (e.g., ifnullor a primitive is passed). Consider typing the parameter asPrisma.InputJsonValueor 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:PlanProductOfferingskeys are not derived fromITEM_IDS, allowing silent drift.If a new entry is added to
ITEM_IDS,PlanProductOfferingsand every plan inPLAN_LIMITSmust be updated manually — TypeScript won't flag a missing field. Deriving the type fromITEM_IDSties 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 repetitiveincludedItemsblocks — 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.
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.
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (1)
apps/backend/src/lib/stripe.tsx (1)
118-183:prisma: {} as anyin 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 anyinstances 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.
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
ProductVersiontable and only pass theproductVersionIdvia stripe metadata instead of the whole product json. ThisproductVersionIdis created by hashing theproductJson. 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
Bug Fixes
Chores