Skip to content

Feat/razorpay billing - #76

Merged
Mayank-saraswal merged 3 commits into
mainfrom
feat/razorpay-billing
Jun 3, 2026
Merged

Feat/razorpay billing#76
Mayank-saraswal merged 3 commits into
mainfrom
feat/razorpay-billing

Conversation

@Mayank-saraswal

@Mayank-saraswal Mayank-saraswal commented Apr 10, 2026

Copy link
Copy Markdown
Owner

Summary by CodeRabbit

Release Notes

  • New Features

    • Added email verification for user registration
    • Integrated subscription billing with multiple pricing tiers (Starter, Pro, Team)
    • Launched dedicated pricing page with feature comparison and FAQ
    • Implemented monthly workflow run quotas based on subscription plan
    • Added SEO enhancements including sitemap and structured data
  • Refactor

    • Migrated billing infrastructure to new payment provider
    • Updated usage tracking to plan-based quota system
    • Improved theme styling with enhanced dark mode support
  • Chores

    • Added web app manifest for PWA support
    • Enhanced page metadata for better social sharing

@coderabbitai

coderabbitai Bot commented Apr 10, 2026

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

This pull request implements a comprehensive billing system overhaul, replacing Polar integration with Razorpay for subscription management, while adding email verification for user signup, implementing quota-based workflow run limits, and updating the pricing/marketing pages with new plan tiers.

Changes

Cohort / File(s) Summary
Sitemap & Web App Manifest
next-sitemap.config.js, public/manifest.json
Added sitemap generation config with exclude patterns and additional routes; added PWA manifest with app identity and icons.
Package Dependencies & Build
package.json
Added next-sitemap, bcryptjs, razorpay, schema-dts dependencies; removed @polar-sh/better-auth and @polar-sh/sdk; added postbuild script for sitemap generation.
Database Migrations
prisma/migrations/20260409154001_razorpay/migration.sql, prisma/migrations/20260409_add_razorpay_billing/migration.sql
Added email verification fields (emailVerifyToken, emailVerifyExpiry, emailVerifyAttempts) to user table; added Razorpay billing columns (plan, planStatus, razorpayCustomerId, razorpaySubId, currentPeriodEnd, workflowRunsUsed, workflowRunsReset); created BillingEvent table for audit logs; dropped FilterOperation enum; added indexes on AggregateNode.nodeId and PostgresNode.nodeId.
Prisma Schema
prisma/schema.prisma
Extended User model with email verification fields, plan/subscription state, and Razorpay identifiers; introduced new BillingEvent model with relation to User for billing transaction tracking.
Email Verification
src/lib/email-verification.ts
Added utilities to generate verification tokens, compute expiry, build verification URLs, and send verification/resend emails via Nodemailer.
Billing & Plan Limits
src/lib/billing.ts, src/lib/plan-limits.ts, src/lib/razorpay-billing.ts
Implemented Razorpay customer/subscription management, quota checking, and run count tracking; defined plan limit constants (FREE, STARTER, PRO, TEAM); created lazy-loaded Razorpay client singleton with Proxy interface.
Authentication Endpoints
src/app/api/auth/custom-signup/route.ts, src/app/api/auth/verify-email/route.ts, src/app/api/auth/resend-verification/route.ts
Added custom signup endpoint with password hashing and verification email sending; implemented email verification validation with expiry and rate-limiting (3 attempts); added resend verification endpoint with attempt tracking.
Email Verification Pages
src/app/(auth)/check-email/page.tsx, src/app/(auth)/verify-email/page.tsx, src/app/(auth)/resend-verification/page.tsx
Added post-signup email verification flow: check inbox page, token verification page with auto-redirect on success, and resend verification form with error handling.
Pricing Pages & Components
src/app/(marketing)/pricing/page.tsx, src/app/(marketing)/pricing/pricing-page.tsx
Added pricing route with metadata; implemented pricing page component with interval toggle, plan cards, feature lists, FAQs, and dynamic pricing display for FREE, STARTER, PRO, and TEAM tiers.
Billing Router & Webhook
src/server/routers/billing.router.ts, src/app/api/webhooks/razorpay-billing/route.ts
Implemented tRPC billing router with subscription creation/cancellation, quota checking, and event history; added Razorpay webhook handler processing subscription lifecycle and payment events with signature verification.
Authentication & Subscription Management
src/lib/auth.ts, src/lib/auth-client.ts, src/lib/execution-gate.ts, src/features/auth/components/subscriptions/hooks/use-subscription.ts
Removed Polar plugin integration; enabled email verification requirement; updated execution limits to use plan-based quotas from database; migrated subscription state from Polar API to local database queries.
Auth Forms & Components
src/features/auth/components/login-form.tsx, src/features/auth/components/register-form.tsx
Updated login form with email-verified banner; switched register flow to use custom signup endpoint with verification redirect and improved error handling for unverified users.
UI Components - Upgrade & Billing
src/components/upgrade-modal.tsx, src/components/upgrade-prompt.tsx, src/components/usage-banner.tsx, src/components/app-sidebar.tsx
Replaced Polar checkout flows with /pricing route navigation; updated UsageBanner to fetch quota from billing router and display plan-specific run limits; updated sidebar upgrade/billing buttons.
Pricing & Plan Data
src/components/landing/marketing-page.tsx
Updated plan tiers: Free (100 runs, 5 workflows), added Starter (₹999, 10,000 runs), Pro (₹2,499, 100,000 runs, featured), replaced Enterprise with Team (₹5,999, 500,000 runs); adjusted grid layout.
Metadata & SEO
src/app/layout.tsx, src/app/opengraph-image.tsx, src/components/structured-data.tsx
Enhanced metadata with India-focused descriptions, keywords, canonical URLs, and verification tokens; added Open Graph image generation; introduced Schema.org JSON-LD for SoftwareApplication and Organization.
Styling & Theme
src/app/globals.css
Updated .dark theme with near-pure black palette, orange brand utilities, increased shadow opacities, and dark-mode marketing component overrides; added smooth scroll behavior.
Workflow & Execution
src/inngest/functions/schedule-poller.ts, src/features/workflows/hooks/use-workflows.ts
Changed schedule poller cron from every minute to daily at 09:00; updated cache invalidation to use new billing router instead of usage router.
Removed Code & Cleanup
src/lib/polar.ts, src/server/routers/usage.router.ts, src/app/api/polar/webhook/route.ts, src/features/triggers/components/.../*.ts
Removed Polar client initialization; deleted usage router (replaced by billing router); removed Polar webhook handler; removed unused retry imports from trigger executors.
Utility Components & Styling
src/components/entity-components.tsx
Updated EmptyView background/border styling to use semantic color tokens instead of hardcoded white.
Setup Script
scripts/create-razorpay-plans.ts
Added executable script to create three Razorpay subscription plans (STARTER, PRO, TEAM) via API and output .env configuration entries.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • Postgres db node #71: Overlapping database schema changes including FilterOperation enum removal and AggregateNode/PostgresNode index additions.
  • Postgres after fix #73: Related Prisma/PostgreSQL schema modifications affecting PostgresNode indexing and type definitions.

Poem

🐰 Hop hop, the billing's now complete,
Razorpay makes the plan so sweet,
Emails verified, quotas checked with care,
A pricing page beyond compare,
From Polar gone, new dreams appear! 🎉

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 45.71% 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
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main change: introducing Razorpay billing integration. It accurately summarizes the primary objective of the extensive changeset.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/razorpay-billing

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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 15a04ec9ad

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

name: "Schedule Trigger Poller",
},
{ cron: "* * * * *" },
{ cron: "0 9 * * *" },

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Run schedule poller every minute

The poller now runs at 0 9 * * *, but this function only dispatches a workflow when the previous cron tick was within 60 seconds (secondsSincePrev <= 60). With a once-daily poll, most schedules (hourly, every 5 minutes, etc.) will never satisfy that condition except near 09:00, so scheduled workflows are effectively missed for the rest of the day.

Useful? React with 👍 / 👎.

monthlyPrice: 999,
yearlyPrice: 799,
cta: "Start 14-day Trial",
ctaHref: "/signup?plan=starter",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Route paid plan CTAs to an authenticated billing flow

The paid-tier CTA points to /signup?plan=..., which is not usable for existing logged-in users coming from in-app upgrade entry points; the signup route is unauth-only and redirects authenticated sessions, so they cannot actually start a subscription from pricing. This is a regression from the previous direct checkout actions and blocks upgrades for current customers.

Useful? React with 👍 / 👎.

Comment on lines +87 to +88
razorpayEventId:
(event.account_id as string) || `${subId}-activated`,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Use a unique webhook event ID for activation records

For subscription.activated, razorpayEventId is set from event.account_id, but account_id is merchant-level and repeats across events while the DB column is unique. After the first activation, later inserts will hit unique-constraint errors and skip recording activation events, which breaks billing history/audit completeness.

Useful? React with 👍 / 👎.

Comment on lines +27 to +30
emailVerifyToken: token,
emailVerifyExpiry: getTokenExpiry(),
emailVerifyAttempts: 0,
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep resend-attempt counters when re-signing up

When an unverified account re-submits signup, this path resets emailVerifyAttempts to 0. That nullifies the 3-attempt resend cap in /api/auth/resend-verification, allowing unlimited verification-email sends by repeatedly calling signup and defeating the anti-spam/rate-limit logic.

Useful? React with 👍 / 👎.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 3

Note

Due to the large number of review comments, Critical severity comments were prioritized as inline comments.

Caution

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

⚠️ Outside diff range comments (2)
src/features/auth/components/login-form.tsx (1)

212-212: ⚠️ Potential issue | 🟡 Minor

Typo: Double apostrophe in text.

Line 212 contains Don&apos;'t which renders as "Don''t" instead of "Don't".

🐛 Proposed fix
-                                    Don&apos;'t have an account?{" "}
+                                    Don&apos;t have an account?{" "}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/auth/components/login-form.tsx` at line 212, In the LoginForm
component in login-form.tsx there is a typo in the displayed string: replace the
incorrect `Don&apos;'t have an account?` with the correct `Don&apos;t have an
account?` (or simply "Don't have an account?") so the text renders as "Don't
have an account?" instead of "Don''t".
src/components/landing/marketing-page.tsx (1)

1014-1016: ⚠️ Potential issue | 🟠 Major

Bug: "Enterprise" tier check should be "Team".

The condition tier.title === "Enterprise" will never match since the tier was renamed to "Team". This causes the Team tier CTA to incorrectly link to /sign-up instead of the expected contact/sales behavior.

🐛 Proposed fix
               <Link
-                href={tier.title === "Enterprise" ? "#" : "/sign-up"}
+                href={tier.title === "Team" ? "#" : "/sign-up"}
                 className={`mt-8 block rounded-xl py-3 text-center font-[family-name:var(--font-dm-sans)] text-sm font-medium transition-colors ${
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/landing/marketing-page.tsx` around lines 1014 - 1016, The CTA
link condition is checking tier.title === "Enterprise" but the tier was renamed
to "Team", so update the conditional in the Link component (the href expression
that uses tier.title) to check for "Team" instead of "Enterprise" so the Team
tier renders the contact/sales behavior (i.e., uses the "#" or sales URL) rather
than linking to "/sign-up"; ensure this change is applied where the Link with
className and href is defined in the marketing-page component.
🟠 Major comments (18)
src/lib/email-verification.ts-44-108 (1)

44-108: ⚠️ Potential issue | 🟠 Major

Escape user-controlled values before templating the email.

userName is inserted directly into both email bodies. A crafted display name can break the HTML or inject arbitrary text into the message content.

🛡️ Proposed fix
 import nodemailer from "nodemailer"
 import crypto from "crypto"
+import { encode } from "html-entities"
+
+function sanitizeDisplayName(value: string): string {
+  return value.replace(/[\r\n]+/g, " ").trim()
+}
@@
   const verifyUrl = buildVerifyUrl(token)
+  const safeUserName = sanitizeDisplayName(userName)
+  const safeUserNameHtml = encode(safeUserName)
@@
-                Hi ${userName}, click the button below to verify your email and
+                Hi ${safeUserNameHtml}, click the button below to verify your email and
@@
-Hi ${userName}, click the link below to verify your email address:
+Hi ${safeUserName}, click the link below to verify your email address:
@@
   const verifyUrl = buildVerifyUrl(token)
+  const safeUserName = sanitizeDisplayName(userName)
+  const safeUserNameHtml = encode(safeUserName)
@@
-                Hi ${userName}, click the button below to verify your email and
+                Hi ${safeUserNameHtml}, click the button below to verify your email and
@@
-Hi ${userName}, click the link below to verify your email address:
+Hi ${safeUserName}, click the link below to verify your email address:

Also applies to: 133-197

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

In `@src/lib/email-verification.ts` around lines 44 - 108, The templates insert
user-controlled values (userName and verifyUrl) directly which can break HTML or
enable injection; create and use an escaping helper (e.g., escapeHtml) to
replace & < > " ' for any value interpolated into the HTML template (use
escapeHtml(userName) and escapeHtml(verifyUrl) in href/text nodes), and
URL-encode verifyUrl for attributes using encodeURI/encodeURIComponent when
building href values; also ensure the plain-text body uses a safe
sanitized/plain-escaped userName (or strip control/newline characters) before
interpolation. Update every occurrence in this file (including the other
template region referenced at 133-197) to use these helpers (e.g.,
escapeHtml(userName), encodeURI(verifyUrl)) instead of raw variables.
src/lib/email-verification.ts-17-19 (1)

17-19: ⚠️ Potential issue | 🟠 Major

Avoid defaulting verification links to production.

When NEXT_PUBLIC_APP_URL is unset, every verification email points at https://nodebase.tech. That will misroute preview/local/staging signups to the wrong environment.

🌐 Proposed fix
 export function buildVerifyUrl(token: string): string {
-  const baseUrl = process.env.NEXT_PUBLIC_APP_URL || "https://nodebase.tech"
-  return `${baseUrl}/verify-email?token=${token}`
+  const baseUrl =
+    process.env.BETTER_AUTH_URL ??
+    process.env.NEXT_PUBLIC_APP_URL ??
+    "http://localhost:3000"
+
+  const url = new URL("/verify-email", baseUrl)
+  url.searchParams.set("token", token)
+  return url.toString()
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/email-verification.ts` around lines 17 - 19, The buildVerifyUrl
function currently defaults to "https://nodebase.tech" when NEXT_PUBLIC_APP_URL
is missing; change it so it does not point to production by default: compute
baseUrl as process.env.NEXT_PUBLIC_APP_URL ||
(process.env.NEXT_PUBLIC_VERCEL_URL ?
`https://${process.env.NEXT_PUBLIC_VERCEL_URL}` :
`http://localhost:${process.env.PORT || 3000}`), and in production (NODE_ENV ===
'production') if NEXT_PUBLIC_APP_URL is still unset throw or log an error to
force the env to be configured; update buildVerifyUrl accordingly so it uses
this computed baseUrl (or accept an optional baseUrl parameter) instead of the
hardcoded nodebase.tech fallback.
src/hooks/use-razorpay.ts-20-47 (1)

20-47: ⚠️ Potential issue | 🟠 Major

Use the server-returned checkout key.

src/server/routers/billing.router.ts:41-67 already returns keyId alongside subscriptionId, but this hook ignores it and re-reads the public env var. If those ever drift, checkout fails after the subscription has already been created.

🔧 Proposed fix
   const openSubscriptionCheckout = async ({
+    keyId,
     subscriptionId,
     userEmail,
     userName,
     plan,
@@
   }: {
+    keyId: string
     subscriptionId: string
     userEmail: string
     userName: string
@@
     const options = {
-      key: process.env.NEXT_PUBLIC_RAZORPAY_KEY_ID,
+      key: keyId,
       subscription_id: subscriptionId,
       name: "Nodebase",
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/use-razorpay.ts` around lines 20 - 47, The hook is ignoring the
server-provided checkout key and uses process.env.NEXT_PUBLIC_RAZORPAY_KEY_ID;
update openSubscriptionCheckout to accept the server-returned keyId (e.g., add
keyId: string to the argument object) and set options.key = keyId (instead of
process.env.NEXT_PUBLIC_RAZORPAY_KEY_ID) when building the Razorpay options for
checkout (keep subscription_id: subscriptionId as-is); adjust callers to pass
the keyId returned from the billing router.
public/manifest.json-1-13 (1)

1-13: ⚠️ Potential issue | 🟠 Major

Move manifest to app/manifest.json or explicitly wire it into the root layout.

public/manifest.json is currently unreferenced and has no effect. For a Next.js App Router project, the recommended approach is to place the manifest at app/manifest.json so Next.js automatically serves it and handles it as a metadata file. Alternatively, if keeping it in public/, add the reference to the metadata object in src/app/layout.tsx or include a <link rel="manifest" href="/manifest.json"> in the head.

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

In `@public/manifest.json` around lines 1 - 13, Move the manifest into the App
Router metadata path or explicitly reference it from the root layout: either
move public/manifest.json to app/manifest.json so Next.js serves it
automatically, or keep public/manifest.json and add a <link rel="manifest"
href="/manifest.json"> (or include it in the metadata object) inside your root
layout component (src/app/layout.tsx) to ensure the manifest is linked and used
by the app.
src/features/auth/components/register-form.tsx-93-93 (1)

93-93: ⚠️ Potential issue | 🟠 Major

Avoid placing raw email in URL query params.
At Line 93, user email is added to the URL. That exposes PII in logs/history/referrers.

🔧 Suggested fix
-                router.push(`/check-email?email=${encodeURIComponent(values.email)}`);
+                sessionStorage.setItem("pendingVerificationEmail", values.email);
+                router.push("/check-email");

And in src/app/(auth)/check-email/page.tsx, read from sessionStorage (with a generic fallback) instead of useSearchParams.

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

In `@src/features/auth/components/register-form.tsx` at line 93, The register form
currently appends the raw email to the URL via
router.push(`/check-email?email=${encodeURIComponent(values.email)}`); instead
store the email in sessionStorage (e.g. setItem with a key like "authEmail") and
navigate to router.push('/check-email') without query params; then update the
check-email page (src/app/(auth)/check-email/page.tsx) to read
sessionStorage.getItem('authEmail') with a safe generic fallback if missing and
clear the stored value after use, so PII is not exposed in query strings or
logs.
next-sitemap.config.js-34-37 (1)

34-37: ⚠️ Potential issue | 🟠 Major

Add /api/ to Googlebot's disallow list to prevent unintended API crawling.

Googlebot uses only its specific user-agent group and does not inherit rules from the * group. Since the Googlebot policy omits /api/ from its disallow list while the wildcard group blocks it, Googlebot can crawl API endpoints.

🔧 Suggested fix
       {
         userAgent: "Googlebot",
         allow: "/",
-        disallow: ["/workflows/", "/editor/", "/settings/"],
+        disallow: ["/workflows/", "/editor/", "/settings/", "/api/"],
       },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@next-sitemap.config.js` around lines 34 - 37, The Googlebot robots entry
(userAgent: "Googlebot") omits "/api/" so Googlebot can crawl API endpoints;
update the disallow array on that Googlebot rule (the object with userAgent
"Googlebot", allow, disallow) to include "/api/" alongside "/workflows/",
"/editor/", and "/settings/" so Googlebot is blocked from /api/ paths as
intended.
src/lib/plan-limits.ts-2-6 (1)

2-6: ⚠️ Potential issue | 🟠 Major

Fail fast when a Razorpay plan ID is missing.

Defaulting these values to "" lets the app boot with a broken billing configuration and defers the failure until someone tries to subscribe. Prefer validating the env upfront, or keep these as nullable/undefined so the checkout path can reject misconfiguration explicitly.

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

In `@src/lib/plan-limits.ts` around lines 2 - 6, RAZORPAY_PLAN_IDS currently
defaults missing env vars to empty strings which allows the app to boot with
broken billing; change this to fail fast by reading the env vars without a ""
fallback (e.g. keep them possibly undefined) and add a startup validation
function (e.g. validateRazorpayPlanIds or assertRazorpayPlans) that checks
RAZORPAY_PLAN_IDS.STARTER/PRO/TEAM and throws a clear error if any are missing;
call that validator during app initialization so the process exits immediately
on misconfiguration.
src/lib/execution-gate.ts-24-25 (1)

24-25: ⚠️ Potential issue | 🟠 Major

Gate paid limits on planStatus === "active".

Line 24 trusts user.plan alone, so any account with a persisted paid tier keeps the higher quota even when the subscription status is no longer active. This should fall back to FREE unless the paid plan is explicitly active.

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

In `@src/lib/execution-gate.ts` around lines 24 - 25, The code reads the paid tier
from user.plan without checking subscription state, so change the logic that
sets plan in execution gate to use the paid plan only when user.planStatus ===
"active" (otherwise treat as "FREE"); update the lines that derive plan and
limits (the variables plan and limits, and any usage of PLAN_LIMITS) to compute
plan = (user.planStatus === "active" ? user.plan : "FREE") as PlanKey and then
get limits = PLAN_LIMITS[plan] ?? PLAN_LIMITS.FREE so inactive or cancelled
subscriptions fall back to FREE.
scripts/create-razorpay-plans.ts-51-57 (1)

51-57: ⚠️ Potential issue | 🟠 Major

Make plan creation idempotent to prevent duplicates on script reruns.

This script unconditionally creates three new Razorpay plans on every invocation. Running it more than once (due to accidental reruns or CI retries) will create duplicate billing plans and stale environment variable references, leaving the Razorpay account in an inconsistent state. Before calling razorpay.plans.create(), either check if a plan with matching properties already exists and reuse it, or require an explicit flag to override existing plans.

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

In `@scripts/create-razorpay-plans.ts` around lines 51 - 57, The script currently
creates plans unconditionally in the loop over "plans" by calling
razorpay.plans.create(plan), causing duplicate plans on reruns; update the loop
in create-razorpay-plans.ts to first search for an existing plan matching unique
properties (e.g., plan.item.name, plan.period, plan.amount) using the Razorpay
list/fetch API (e.g., razorpay.plans.all or equivalent) and, if found, reuse
that plan's id instead of calling razorpay.plans.create; alternatively add a CLI
flag (e.g., --force or --override) that, when present, forces creation,
otherwise the script must reuse found plans and print the existing
RAZORPAY_PLAN_<TIER>_ID values (use variables like plans, plan.item.name,
tierName, razorpay.plans.create to locate where to change).
scripts/create-razorpay-plans.ts-62-62 (1)

62-62: ⚠️ Potential issue | 🟠 Major

Propagate script failures via the process exit code.

createPlans().catch(console.error) logs errors but exits with code 0, making setup or CI scripts think provisioning succeeded even when API calls fail.

Suggested fix
-createPlans().catch(console.error)
+createPlans().catch((error) => {
+  console.error(error)
+  process.exitCode = 1
+})
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/create-razorpay-plans.ts` at line 62, The script currently calls
createPlans().catch(console.error) which logs errors but leaves the process exit
code as 0; change the catch to both log the error and set a non‑zero exit code
so failures propagate to CI (e.g., in the promise rejection handler for
createPlans use console.error to print the error and then set process.exitCode =
1 or call process.exit(1)). Ensure you update the invocation at createPlans() to
use this explicit error handler so any API or provisioning failure causes a
non‑zero exit.
src/app/api/auth/custom-signup/route.ts-32-37 (1)

32-37: ⚠️ Potential issue | 🟠 Major

Don't return VERIFICATION_SENT when delivery failed.

Both branches swallow sendVerificationEmail() errors and still return success. Because sign-in is blocked until verification, this leaves the user stranded with an account they cannot activate.

Also applies to: 75-79

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

In `@src/app/api/auth/custom-signup/route.ts` around lines 32 - 37, The current
handler swallows errors from sendVerificationEmail and always returns
NextResponse.json({ success: "VERIFICATION_SENT", ... }), leaving users
stranded; update both occurrences where sendVerificationEmail is called (the
try/catch blocks around sendVerificationEmail in route.ts) to not treat a
failure as success—either rethrow the emailError so the request fails or return
a failure response (e.g., NextResponse.json({ success: "VERIFICATION_FAILED",
error: emailError.message }) with appropriate status) instead of
"VERIFICATION_SENT", and ensure logging includes the error; locate the
sendVerificationEmail calls and replace the catch behavior accordingly.
src/lib/billing.ts-113-126 (1)

113-126: ⚠️ Potential issue | 🟠 Major

The reset window is based on calendar months, not the billing period.

With this logic, a renewal on January 31 can reset usage again on February 1. The webhook in src/app/api/webhooks/razorpay-billing/route.ts:119-132 already resets counters on successful renewal, so this can grant extra quota between renewals.

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

In `@src/lib/billing.ts` around lines 113 - 126, The calendar-month reset using
workflowRunsReset and monthsPassed is incorrect for billing periods and can
grant extra quota; replace this logic so resets occur only when the stored
billing/renewal boundary is reached (or rely solely on the existing webhook that
resets counters). Concretely, remove/disable the monthsPassed calendar
calculation and instead compare user.workflowRunsReset against the
subscription's actual renewal timestamp/period (e.g., nextBillingDate or
billingIntervalDays stored on the user/subscription) and only call
prisma.user.update({ data: { workflowRunsUsed: 0, workflowRunsReset: now } })
when that renewal boundary has passed (or remove this auto-reset entirely and
keep the webhook in route.ts to perform resets).
src/lib/billing.ts-140-144 (1)

140-144: ⚠️ Potential issue | 🟠 Major

Quota enforcement is not atomic.

If multiple workers call checkRunQuota() and then incrementRunCount() concurrently, they can all pass the read check and push workflowRunsUsed past the plan limit.

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

In `@src/lib/billing.ts` around lines 140 - 144, The incrementRunCount
implementation is not atomic and can race past plan limits; change it to perform
a conditional atomic update (e.g., use prisma.user.updateMany or a transactional
update) that includes the condition workflowRunsUsed < plan limit in the WHERE
clause so the DB only increments when the user is still under quota, then check
the affected row count and throw or return an error if zero; locate and modify
incrementRunCount (and, if needed, the related checkRunQuota call) so the update
is performed in one atomic DB operation and failures are handled accordingly.
src/server/routers/billing.router.ts-61-66 (1)

61-66: ⚠️ Potential issue | 🟠 Major

Reject misconfigured checkout on the server.

If NEXT_PUBLIC_RAZORPAY_KEY_ID is unset, this returns keyId: undefined and the failure only surfaces later in the browser checkout flow.

Suggested guard
       const result = await createSubscription(userId, input.plan)
+      const keyId = process.env.NEXT_PUBLIC_RAZORPAY_KEY_ID
+      if (!keyId) {
+        throw new TRPCError({
+          code: "INTERNAL_SERVER_ERROR",
+          message: "Razorpay checkout is not configured",
+        })
+      }
 
       return {
         subscriptionId: result.subscriptionId,
-        keyId: process.env.NEXT_PUBLIC_RAZORPAY_KEY_ID,
+        keyId,
       }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/server/routers/billing.router.ts` around lines 61 - 66, The route returns
keyId possibly undefined; add a guard after calling createSubscription (in the
function handling this route, referencing createSubscription and the returned
result) to validate process.env.NEXT_PUBLIC_RAZORPAY_KEY_ID is set and
non-empty, and if not, throw/return an appropriate server error (e.g.,
BadRequest or internal error) so the checkout is rejected server-side rather
than returning keyId: undefined to the client.
src/lib/billing.ts-110-111 (1)

110-111: ⚠️ Potential issue | 🟠 Major

Quota checks ignore subscription state.

checkRunQuota() keys only off user.plan. If a webhook marks the account past_due or cancelled but leaves the last paid plan on the row, this still returns the paid quota.

Possible fix
-  const plan = (user.plan || "FREE") as PlanKey
+  const plan =
+    user.planStatus === "active"
+      ? ((user.plan || "FREE") as PlanKey)
+      : "FREE"
   const limits = PLAN_LIMITS[plan] ?? PLAN_LIMITS.FREE
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/billing.ts` around lines 110 - 111, checkRunQuota currently derives
plan solely from user.plan which ignores subscription state; update
checkRunQuota to first determine an effective plan by checking the user's
subscription status (e.g., user.subscription?.status, user.subscriptionStatus,
or similar field used to store webhook state) and treat non-active statuses like
"past_due", "canceled", or "cancelled" as FREE (or fallback to
PLAN_LIMITS.FREE), then use that effective plan when looking up limits via
PLAN_LIMITS[plan]; adjust the code paths referencing user.plan (and the line
using PLAN_LIMITS[plan] ?? PLAN_LIMITS.FREE) to use the new effectivePlan
variable so quota enforcement reflects subscription state.
src/app/api/auth/custom-signup/route.ts-52-72 (1)

52-72: ⚠️ Potential issue | 🟠 Major

Create the user and account in one transaction.

If prisma.account.create() fails after prisma.user.create(), the email stays occupied by a half-created user and subsequent retries never recreate the missing credential account.

Suggested transaction
-    const user = await prisma.user.create({
-      data: {
-        id: createId(),
-        email,
-        name: name || email.split("@")[0],
-        emailVerified: false,
-        emailVerifyToken: verifyToken,
-        emailVerifyExpiry: verifyExpiry,
-      },
-    })
-    
-    // Also create account for password login
-    await prisma.account.create({
-      data: {
-        id: createId(),
-        userId: user.id,
-        accountId: email,
-        providerId: "credential",
-        password: hashedPassword,
-      }
-    })
+    const user = await prisma.$transaction(async (tx) => {
+      const createdUser = await tx.user.create({
+        data: {
+          id: createId(),
+          email,
+          name: name || email.split("@")[0],
+          emailVerified: false,
+          emailVerifyToken: verifyToken,
+          emailVerifyExpiry: verifyExpiry,
+        },
+      })
+
+      await tx.account.create({
+        data: {
+          id: createId(),
+          userId: createdUser.id,
+          accountId: email,
+          providerId: "credential",
+          password: hashedPassword,
+        },
+      })
+
+      return createdUser
+    })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/api/auth/custom-signup/route.ts` around lines 52 - 72, The user and
account creations must be performed atomically to avoid orphaned users if
prisma.account.create fails; replace the separate prisma.user.create and
prisma.account.create calls with a single transactional operation (e.g.,
prisma.$transaction or a nested create) that creates the user and the credential
account together using the same id (referencing createId(), verifyToken,
verifyExpiry, hashedPassword and user.id), so both inserts succeed or both roll
back and handle/report unique constraint errors accordingly.
src/app/api/auth/custom-signup/route.ts-17-37 (1)

17-37: ⚠️ Potential issue | 🟠 Major

This bypasses the resend throttle for unverified accounts.

This branch regenerates and sends a token on every signup retry, and it also resets emailVerifyAttempts to 0. That makes the 3-attempt cap in src/app/api/auth/resend-verification/route.ts ineffective.

Safer direction
-        await prisma.user.update({
+        if (existing.emailVerifyAttempts >= 3) {
+          return NextResponse.json(
+            { error: "Too many verification attempts. Please try again later." },
+            { status: 429 }
+          )
+        }
+
+        await prisma.user.update({
           where: { email },
           data: {
             emailVerifyToken: token,
             emailVerifyExpiry: getTokenExpiry(),
-            emailVerifyAttempts: 0,
+            emailVerifyAttempts: { increment: 1 },
           },
         })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/api/auth/custom-signup/route.ts` around lines 17 - 37, The signup
branch currently regenerates a new verify token and resets emailVerifyAttempts
to 0 for any unverified existing user, which bypasses the resend throttle;
change this so you first read existing.emailVerifyAttempts and emailVerifyExpiry
and only regenerate a new token if attempts are below the cap and/or the
previous token is expired, otherwise return an error/ throttle response; when
sending a verification attempt increment emailVerifyAttempts (do not reset to 0)
and update emailVerifyExpiry only when you actually generate a new token
(functions/fields to modify: generateVerifyToken, prisma.user.update
(emailVerifyToken, emailVerifyExpiry, emailVerifyAttempts),
sendVerificationEmail, and the logic compatible with the resend-verification
route’s 3-attempt cap).
src/app/api/webhooks/razorpay-billing/route.ts-83-93 (1)

83-93: ⚠️ Potential issue | 🟠 Major

razorpayEventId uses non-unique values, breaking webhook idempotency.

event.account_id is the merchant's account ID (same for all events), not a unique event identifier. The fallback ${subId}-activated is also not unique across webhook retries.

Razorpay provides the unique event identifier in the x-razorpay-event-id request header, not in the JSON payload. This header should be used to uniquely identify webhook deliveries and prevent duplicate processing due to retries.

Without a unique identifier, duplicate webhook deliveries will cause database constraint violations instead of being handled idempotently.

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

In `@src/app/api/webhooks/razorpay-billing/route.ts` around lines 83 - 93, The
code is using event.account_id and `${subId}-activated` for
prisma.billingEvent.create.razorpayEventId which are not unique and will break
webhook idempotency; change to read the unique Razorpay delivery ID from the
x-razorpay-event-id request header (e.g. via headers.get or the request object
used in this route) and use that value as the razorpayEventId when calling
prisma.billingEvent.create (fall back to a clearly namespaced header-derived or
generated UUID only if the header is missing), ensuring prisma.billingEvent
uniqueness uses that header value so retries are idempotent; update any logic
referencing event.account_id or subId for this field accordingly.
🟡 Minor comments (6)
src/hooks/use-razorpay.ts-5-17 (1)

5-17: ⚠️ Potential issue | 🟡 Minor

Make the checkout script loader idempotent.

Two checkout attempts before the first script finishes loading will append two checkout.js tags. Cache the in-flight promise so every caller awaits the same load.

♻️ Proposed fix
 "use client"
 
+let razorpayLoader: Promise<boolean> | null = null
+
 // Load Razorpay checkout.js script dynamically
 export function useRazorpay() {
   const loadRazorpay = (): Promise<boolean> => {
-    return new Promise((resolve) => {
+    if (razorpayLoader) return razorpayLoader
+
+    razorpayLoader = new Promise((resolve) => {
       if ((window as unknown as Record<string, unknown>).Razorpay) {
         resolve(true)
         return
       }
+
+      const existing = document.querySelector<HTMLScriptElement>(
+        'script[src="https://checkout.razorpay.com/v1/checkout.js"]'
+      )
+      if (existing) {
+        existing.addEventListener("load", () => resolve(true), { once: true })
+        existing.addEventListener(
+          "error",
+          () => {
+            razorpayLoader = null
+            resolve(false)
+          },
+          { once: true }
+        )
+        return
+      }
+
       const script = document.createElement("script")
       script.src = "https://checkout.razorpay.com/v1/checkout.js"
       script.async = true
       script.onload = () => resolve(true)
-      script.onerror = () => resolve(false)
+      script.onerror = () => {
+        razorpayLoader = null
+        resolve(false)
+      }
       document.body.appendChild(script)
     })
+
+    return razorpayLoader
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/use-razorpay.ts` around lines 5 - 17, The loadRazorpay function is
not idempotent and can append multiple checkout.js tags if called before the
first load completes; introduce a module-scoped promise (e.g.,
razorpayLoadPromise) that caches the in-flight Promise<boolean> and return it
when present, and before creating a new script also check for an existing script
tag (by src or a specific id) to avoid duplicates; keep the existing
onload/onerror handlers to resolve true/false and set the cached promise
accordingly so all callers await the same load.
src/features/auth/components/register-form.tsx-91-99 (1)

91-99: ⚠️ Potential issue | 🟡 Minor

Harden error parsing for non-JSON responses.
At Line 91, unconditional res.json() can throw on empty/HTML responses and misclassify failures as network errors.

🔧 Suggested fix
-            const data = await res.json();
+            const contentType = res.headers.get("content-type") || "";
+            const data = contentType.includes("application/json")
+              ? await res.json()
+              : { error: await res.text() };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/auth/components/register-form.tsx` around lines 91 - 99, The
code currently calls await res.json() unconditionally which will throw on
non-JSON or empty responses and cause those cases to be reported as "Network
error"; update the response parsing in the form submit handler (the block that
uses res, data, values.email, router.push, and toast.error) to first check for a
JSON content-type (res.headers.get('content-type')) and only call res.json()
when appropriate, otherwise call res.text() and build a fallback data object
(e.g., { error: text || res.statusText || 'Unknown error' }) so that
non-JSON/empty responses are reported correctly; keep the existing catch to
handle real network exceptions.
src/app/globals.css-332-335 (1)

332-335: ⚠️ Potential issue | 🟡 Minor

Respect reduced-motion preferences for global smooth scrolling.

Applying smooth scrolling globally adds motion for anchor and keyboard navigation even when the user has requested reduced motion.

Suggested fix
-/* Smooth scroll globally */
-html {
-  scroll-behavior: smooth;
-}
+@media (prefers-reduced-motion: no-preference) {
+  html {
+    scroll-behavior: smooth;
+  }
+}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/globals.css` around lines 332 - 335, The global html {
scroll-behavior: smooth; } rule ignores users' reduced-motion preference; wrap
the smooth scrolling rule in a media query using prefers-reduced-motion:
no-preference (and optionally add a prefers-reduced-motion: reduce rule to set
scroll-behavior: auto) so that smooth scrolling is only applied when the user
hasn’t requested reduced motion; update the css selectors referencing html and
scroll-behavior accordingly.
src/app/(auth)/verify-email/page.tsx-15-46 (1)

15-46: ⚠️ Potential issue | 🟡 Minor

Clean up the verification side effects.

This effect can still update state and fire the delayed redirect after the page unmounts or the token changes. Add request and timeout cleanup to prevent stale verification responses from updating state or navigating after the component is gone.

Suggested fix
  useEffect(() => {
+    const controller = new AbortController()
+    let redirectTimer: ReturnType<typeof setTimeout> | undefined
+
     if (!token) {
       setStatus("error")
       setMessage("No verification token found. Check your email for the correct link.")
-      return
+      return () => controller.abort()
     }

-    // Call the verify API
     fetch("/api/auth/verify-email", {
       method: "POST",
       headers: { "Content-Type": "application/json" },
       body: JSON.stringify({ token }),
+      signal: controller.signal,
     })
       .then(res => res.json())
       .then(data => {
+        if (controller.signal.aborted) return
+
         if (data.success) {
           setStatus("success")
           setMessage("Email verified! Redirecting to login...")
-          setTimeout(() => router.push("/login?verified=true"), 2000)
+          redirectTimer = setTimeout(() => router.push("/login?verified=true"), 2000)
         } else if (data.error === "TOKEN_EXPIRED") {
           setStatus("expired")
           setMessage("This verification link has expired. Request a new one below.")
         } else {
           setStatus("error")
           setMessage(data.error || "Verification failed. The link may be invalid.")
         }
       })
       .catch(() => {
+        if (controller.signal.aborted) return
         setStatus("error")
         setMessage("Network error. Please try again.")
       })
+
+    return () => {
+      controller.abort()
+      if (redirectTimer) clearTimeout(redirectTimer)
+    }
   }, [token, router])
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/`(auth)/verify-email/page.tsx around lines 15 - 46, The effect in
useEffect that calls fetch("/api/auth/verify-email") and starts a setTimeout can
update state (setStatus, setMessage) or call router.push after the component
unmounts or token changes; fix it by creating an AbortController for the POST
request and capturing its signal in fetch, store the timeout id returned from
setTimeout, and in the effect cleanup abort the fetch (controller.abort()) and
clear the timeout (clearTimeout). Also guard state updates/navigation by
checking the fetch response is not aborted (or check controller.signal.aborted)
before calling setStatus/setMessage/router.push so stale responses don't mutate
unmounted component state; keep the dependency array [token, router] unchanged.
src/app/api/auth/verify-email/route.ts-6-13 (1)

6-13: ⚠️ Potential issue | 🟡 Minor

Validate the parsed body before destructuring.

req.json() can return null, arrays, or other non-object JSON. Destructuring first throws and turns a bad request into a 500 instead of the intended 400.

Possible fix
-    const { token } = await req.json() as { token: string }
-
-    if (!token || typeof token !== "string") {
+    const body: unknown = await req.json()
+    const token =
+      body && typeof body === "object" && "token" in body
+        ? (body as { token?: unknown }).token
+        : undefined
+
+    if (typeof token !== "string" || token.length === 0) {
       return NextResponse.json(
         { error: "Invalid token" },
         { status: 400 }
       )
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/api/auth/verify-email/route.ts` around lines 6 - 13, Parse the
request body into a variable (e.g., const body = await req.json()) and validate
that body is a plain object and contains a string token before destructuring; if
body is null, an array, or missing/invalid token, return NextResponse.json({
error: "Invalid token" }, { status: 400 }). Update the code around the token
extraction in route.ts to use this safe check instead of directly doing const {
token } = await req.json(), and ensure subsequent logic uses the validated token
variable.
src/app/api/webhooks/razorpay-billing/route.ts-119-131 (1)

119-131: ⚠️ Potential issue | 🟡 Minor

Period end calculation ignores Razorpay's authoritative data.

Calculating currentPeriodEnd as now + 1 month can drift from the actual billing cycle. For subscription.charged events, Razorpay provides the subscription's current_end timestamp in the payload. Consider extracting and using that value instead of computing it manually.

🔧 Proposed fix to use Razorpay's period data
+      // Extract subscription data if available (subscription.charged event)
+      const sub = (
+        payload.subscription as Record<string, unknown>
+      )?.entity as Record<string, unknown> | undefined
+
-      // Extend subscription period by 1 month
-      const newPeriodEnd = new Date()
-      newPeriodEnd.setMonth(newPeriodEnd.getMonth() + 1)
+      // Use Razorpay's period end if available, otherwise estimate
+      const newPeriodEnd = sub?.current_end
+        ? new Date((sub.current_end as number) * 1000)
+        : (() => {
+            const d = new Date()
+            d.setMonth(d.getMonth() + 1)
+            return d
+          })()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/api/webhooks/razorpay-billing/route.ts` around lines 119 - 131, The
code currently computes newPeriodEnd via new Date().setMonth(...) which can
drift; instead extract the authoritative period end from the Razorpay payload
(the subscription object's current_end timestamp on the subscription.charged
event), convert that timestamp to a JS Date, and use it for the
prisma.user.update currentPeriodEnd field (replace newPeriodEnd). Update the
logic around handling subscription (or event) payload parsing to read
subscription.current_end, fall back to the existing one-month calculation only
if current_end is absent, and ensure you reference the same identifiers used in
the diff (subscription, newPeriodEnd, prisma.user.update, currentPeriodEnd).
🧹 Nitpick comments (9)
package.json (1)

120-120: Drop the deprecated stub typings package.

bcryptjs already ships its own TypeScript declarations, and @types/bcryptjs is now a deprecated stub, so this extra devDependency is just redundant type surface. (npmjs.com)

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

In `@package.json` at line 120, Remove the redundant deprecated stub devDependency
"@types/bcryptjs": "^2.4.6" from package.json (the entry shown under
devDependencies) and update the lockfile by running the package manager (e.g.,
npm install or yarn install) so the lockfile reflects the change; ensure you
keep "bcryptjs" (which provides its own types) and verify there are no other
references to "@types/bcryptjs" in package.json or CI install scripts.
src/app/(marketing)/pricing/pricing-page.tsx (1)

177-195: Add explicit type="button" to toggle buttons.

The interval toggle buttons are missing an explicit type attribute. While they're not inside a form, adding type="button" is a best practice to prevent unexpected behavior.

🔧 Proposed fix
           <button
             id="toggle-monthly"
+            type="button"
             onClick={() => setInterval("monthly")}
             className={`relative z-10 rounded-full px-5 py-1.5 text-sm font-medium transition-colors ${
               interval === "monthly" ? "text-white" : "text-white/50 hover:text-white/70"
             }`}
           >
             Monthly
           </button>
           <button
             id="toggle-yearly"
+            type="button"
             onClick={() => setInterval("yearly")}
             className={`relative z-10 rounded-full px-5 py-1.5 text-sm font-medium transition-colors ${
               interval === "yearly" ? "text-white" : "text-white/50 hover:text-white/70"
             }`}
           >
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/`(marketing)/pricing/pricing-page.tsx around lines 177 - 195, The two
toggle buttons in the pricing page (ids "toggle-monthly" and "toggle-yearly")
should be given an explicit type="button" to avoid implicit form submission;
update the JSX in the pricing-page component where setInterval and interval are
used (the <button id="toggle-monthly"> and <button id="toggle-yearly"> elements)
to include type="button" on each button.
src/app/(auth)/resend-verification/page.tsx (1)

47-49: Unused err variable in catch block.

The caught error is not used. Consider logging it for debugging purposes or removing the variable name.

🔧 Proposed fix
-    } catch (err) {
+    } catch {
       toast.error("Network error. Please try again.")
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/`(auth)/resend-verification/page.tsx around lines 47 - 49, The catch
block in src/app/(auth)/resend-verification/page.tsx currently declares an
unused err variable; either remove the variable (change to catch { ... }) or log
the error for debugging (e.g., console.error(err) or use your app logger) and
keep the toast.error("Network error. Please try again.") behavior; update the
catch clause and body around the toast.error call in that function accordingly.
src/features/auth/components/login-form.tsx (1)

233-238: Add a fallback to the Suspense boundary.

The Suspense component wrapping LoginContent has no fallback prop. While this may work, providing a fallback (even a minimal loading state) improves the user experience during the initial render when useSearchParams suspends.

🔧 Proposed fix
 export function LoginForm() {
   return (
-    <Suspense>
+    <Suspense fallback={<div className="flex flex-col gap-6"><Card><CardContent className="p-6"><div className="animate-pulse h-64 bg-muted rounded" /></CardContent></Card></div>}>
       <LoginContent />
     </Suspense>
   )
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/auth/components/login-form.tsx` around lines 233 - 238, The
Suspense wrapper in LoginForm currently has no fallback — update the Suspense in
the LoginForm function to include a fallback prop that renders a minimal loading
state (for example a small "Loading..." node or the existing Spinner/Loader
component) while LoginContent (which uses useSearchParams) suspends; if you
choose to use a Spinner/Loader, import it and ensure the fallback uses that
component.
src/app/api/auth/resend-verification/route.ts (1)

7-7: Add server-side email validation.

The email is only validated client-side. Consider adding validation server-side to protect against malformed requests or clients bypassing the frontend.

🛡️ Proposed fix
+import { z } from "zod"
+
+const resendSchema = z.object({
+  email: z.string().email(),
+})
+
 export async function POST(req: NextRequest) {
   try {
-    const { email } = await req.json() as { email: string }
+    const body = await req.json()
+    const result = resendSchema.safeParse(body)
+    
+    if (!result.success) {
+      return NextResponse.json({ success: true }) // Still avoid enumeration
+    }
+    
+    const { email } = result.data
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/api/auth/resend-verification/route.ts` at line 7, The handler
currently destructures { email } from await req.json() without verifying it; add
server-side validation in the POST/route handler that checks email exists, is a
string, and matches a sane email format (use a simple RFC-like regex or a
library such as validator.isEmail) before proceeding; if validation fails return
a 400 response with a clear error message. Locate the destructuring line for {
email } in src/app/api/auth/resend-verification/route.ts and add the validation
guard there (or a small helper validateEmail(email)) and short-circuit the flow
on invalid input so downstream logic never receives malformed emails.
src/app/api/webhooks/razorpay-billing/route.ts (4)

270-276: Swallowing all errors prevents retries for transient failures.

Returning 200 on all errors prevents Razorpay from retrying, which is correct for permanent errors (bad data, missing user). However, transient errors (database timeouts, network issues) would benefit from retries. Consider distinguishing error types:

💡 Suggestion for smarter error handling
} catch (error) {
  // Distinguish transient vs permanent errors
  const isTransient = error instanceof Prisma.PrismaClientKnownRequestError 
    && ['P2024', 'P2028'].includes(error.code) // timeout/connection errors
  
  if (isTransient) {
    console.error(`Transient error handling ${eventType}, will retry:`, error)
    return NextResponse.json({ error: "Temporary failure" }, { status: 503 })
  }
  
  console.error(`Permanent error handling ${eventType}:`, error)
  // Return 200 for permanent errors to prevent infinite retries
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/api/webhooks/razorpay-billing/route.ts` around lines 270 - 276, The
catch block in the webhook handler that currently logs errors and always returns
200 should distinguish transient vs permanent failures: detect transient
DB/network errors (e.g., check error instanceof
Prisma.PrismaClientKnownRequestError and match transient codes like P2024/P2028
or other network/timeouts) and, when detected, log as transient and return a 503
NextResponse (so Razorpay will retry); otherwise log as permanent and continue
returning 200 to avoid retries. Use the existing eventType variable for context
in logs and NextResponse.json for the 503/200 responses.

83-93: Webhook handler relies on DB constraint errors instead of proper idempotent handling.

The code uses prisma.billingEvent.create() with a unique razorpayEventId, but doesn't check for existing records or use upsert. Duplicate webhooks (common in production) will throw constraint errors that get swallowed by the catch block. This obscures real errors in logs and is not a clean idempotent pattern.

♻️ Proposed idempotent pattern using upsert
// Example for subscription.activated - apply similar pattern to other events
await prisma.billingEvent.upsert({
  where: { razorpayEventId: eventId },
  create: {
    userId,
    type: eventType,
    razorpayEventId: eventId,
    plan,
    status: "success",
    rawPayload: rawBody,
  },
  update: {}, // No-op on duplicate
})

Also applies to: 134-144, 170-179, 215-223

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

In `@src/app/api/webhooks/razorpay-billing/route.ts` around lines 83 - 93, The
webhook handler currently uses prisma.billingEvent.create(...) with
razorpayEventId as a unique key, which causes duplicate webhook deliveries to
throw constraint errors; replace those create calls (locations around
prisma.billingEvent.create and similar blocks handling event types) with
prisma.billingEvent.upsert using where: { razorpayEventId: eventId } (or the
actual event id variable used) and provide create with the full payload and an
empty update: {} for a no-op on duplicates so processing is idempotent and
duplicate events are ignored rather than causing DB errors; apply this change to
all occurrences (the create usages around lines handling subscription.activated,
other event branches referenced in the comment).

232-265: Paused/resumed events don't create billing history records.

Unlike other event handlers, subscription.paused and subscription.resumed don't create billingEvent records. This creates inconsistent audit trails and could complicate debugging billing issues.

♻️ Proposed fix to add billing events
       await prisma.user.update({
         where: { id: user.id },
         data: { planStatus: "paused" },
       })
+
+      await prisma.billingEvent.create({
+        data: {
+          userId: user.id,
+          type: eventType,
+          razorpayEventId: /* unique event ID from payload */,
+          plan: user.plan,
+          status: "success",
+          rawPayload: rawBody,
+        },
+      })
       break

Apply similar change to subscription.resumed.

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

In `@src/app/api/webhooks/razorpay-billing/route.ts` around lines 232 - 265, The
handlers for "subscription.paused" and "subscription.resumed" update
user.planStatus but do not create billingEvent records; add a
prisma.billingEvent.create call in both case blocks (after the
prisma.user.update) to mirror other event handlers: set type to
"subscription.paused" / "subscription.resumed", set userId to user.id, and
include relevant metadata (e.g., razorpay subscription id subId and any useful
payload/entity fields) so each state change is recorded in billingEvent for
auditability.

5-21: Signature verification may throw on malformed input.

If signature contains invalid hex characters or has an odd length, Buffer.from(signature, "hex") can throw or produce unexpected results. Consider wrapping the buffer conversion in a try-catch or validating hex format first.

🛡️ Proposed defensive fix
 function verifyWebhookSignature(
   body: string,
   signature: string,
   secret: string
 ): boolean {
+  // Validate signature is valid hex before comparison
+  if (!/^[a-f0-9]+$/i.test(signature)) return false
+
   const expected = crypto
     .createHmac("sha256", secret)
     .update(body)
     .digest("hex")

   if (expected.length !== signature.length) return false

-  return crypto.timingSafeEqual(
-    Buffer.from(expected, "hex"),
-    Buffer.from(signature, "hex")
-  )
+  try {
+    return crypto.timingSafeEqual(
+      Buffer.from(expected, "hex"),
+      Buffer.from(signature, "hex")
+    )
+  } catch {
+    return false
+  }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/api/webhooks/razorpay-billing/route.ts` around lines 5 - 21, The
verifyWebhookSignature function can throw when signature isn't valid hex; update
it to defensively validate and safely convert hex buffers: first check that both
expected and signature are non-empty hex strings and have even lengths (or use a
strict regex like /^[0-9a-fA-F]+$/), then attempt Buffer.from(expected,"hex")
and Buffer.from(signature,"hex") inside a try-catch (or pre-validate to avoid
exceptions), and return false on any validation/parse error before calling
crypto.timingSafeEqual so malformed input never causes an exception.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ad70ab8f-7acb-4bd3-b38b-9997ec52f71a

📥 Commits

Reviewing files that changed from the base of the PR and between 4a3eb62 and 15a04ec.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (48)
  • next-sitemap.config.js
  • package.json
  • prisma/migrations/20260409154001_razorpay/migration.sql
  • prisma/migrations/20260409_add_razorpay_billing/migration.sql
  • prisma/schema.prisma
  • public/manifest.json
  • scripts/create-razorpay-plans.ts
  • src/app/(auth)/check-email/page.tsx
  • src/app/(auth)/resend-verification/page.tsx
  • src/app/(auth)/verify-email/page.tsx
  • src/app/(marketing)/pricing/page.tsx
  • src/app/(marketing)/pricing/pricing-page.tsx
  • src/app/api/auth/custom-signup/route.ts
  • src/app/api/auth/resend-verification/route.ts
  • src/app/api/auth/verify-email/route.ts
  • src/app/api/polar/webhook/route.ts
  • src/app/api/webhooks/razorpay-billing/route.ts
  • src/app/globals.css
  • src/app/layout.tsx
  • src/app/opengraph-image.tsx
  • src/components/app-sidebar.tsx
  • src/components/entity-components.tsx
  • src/components/landing/marketing-page.tsx
  • src/components/structured-data.tsx
  • src/components/upgrade-modal.tsx
  • src/components/upgrade-prompt.tsx
  • src/components/usage-banner.tsx
  • src/features/auth/components/login-form.tsx
  • src/features/auth/components/register-form.tsx
  • src/features/auth/components/subscriptions/hooks/use-subscription.ts
  • src/features/triggers/components/google-form-trigger/executor.ts
  • src/features/triggers/components/manual-trigger/executor.ts
  • src/features/triggers/components/stripe-trigger/executor.ts
  • src/features/workflows/hooks/use-workflows.ts
  • src/hooks/use-razorpay.ts
  • src/inngest/functions/schedule-poller.ts
  • src/lib/auth-client.ts
  • src/lib/auth.ts
  • src/lib/billing.ts
  • src/lib/email-verification.ts
  • src/lib/execution-gate.ts
  • src/lib/plan-limits.ts
  • src/lib/polar.ts
  • src/lib/razorpay-billing.ts
  • src/server/routers/billing.router.ts
  • src/server/routers/usage.router.ts
  • src/trpc/init.ts
  • src/trpc/routers/_app.ts
💤 Files with no reviewable changes (6)
  • src/features/triggers/components/google-form-trigger/executor.ts
  • src/features/triggers/components/stripe-trigger/executor.ts
  • src/features/triggers/components/manual-trigger/executor.ts
  • src/lib/polar.ts
  • src/server/routers/usage.router.ts
  • src/app/api/polar/webhook/route.ts

name: "Schedule Trigger Poller",
},
{ cron: "* * * * *" },
{ cron: "0 9 * * *" },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Critical: Daily poll cadence breaks schedule execution semantics.

With { cron: "0 9 * * *" }, the poller runs once per day, but execution still depends on secondsSincePrev <= 60 (Lines 33-37). That means only triggers whose cron fired within the last minute at exactly poll time will run; most schedules/timezones will be skipped.

💡 Suggested fix
-  { cron: "0 9 * * *" },
+  { cron: "* * * * *" },

If daily polling is intentional, you’ll need a different design: persist lastRunAt per trigger and process all missed occurrences between lastRunAt and now instead of a fixed 60-second window.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
{ cron: "0 9 * * *" },
{ cron: "* * * * *" },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/inngest/functions/schedule-poller.ts` at line 10, The poll cadence in
schedule-poller.ts is incorrect for daily schedules: the cron is set to "{ cron:
\"0 9 * * *\" }" but the code still filters triggers using "secondsSincePrev <=
60", causing missed runs; either change the poll cron to run every minute (e.g.,
cron="* * * * *") so the existing secondsSincePrev window works, or implement
per-trigger persistence (add a "lastRunAt" for each trigger and update/process
all missed occurrences between lastRunAt and now instead of using
secondsSincePrev) and remove the 60-second filter; look for the
variables/functions "secondsSincePrev", the cron config in schedule-poller.ts,
and the trigger execution path to update logic and persist lastRunAt
accordingly.

Comment thread src/lib/billing.ts
Comment on lines +56 to +74
const subscription = (await razorpayBilling.subscriptions.create({
plan_id: planId,
customer_notify: 1,
quantity: 1,
total_count: 120, // 10 years max — effectively perpetual
notes: {
userId,
plan,
},
})) as { id: string; short_url?: string }

// Store pending subscription
await prisma.user.update({
where: { id: userId },
data: {
razorpaySubId: subscription.id,
// plan stays as current until payment confirmed via webhook
},
})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Make subscription creation idempotent before the Razorpay call.

A retry or double-click creates another remote subscription and then overwrites user.razorpaySubId locally. That can leave multiple billable subscriptions in Razorpay while only one is tracked here.

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

In `@src/lib/billing.ts` around lines 56 - 74, Before calling
razorpayBilling.subscriptions.create, make subscription creation idempotent by
first checking and reserving a local marker: use prisma.user.findUnique to see
if user.razorpaySubId or a new field (e.g., razorpaySubRequestId) already exists
and return that instead of calling razorpayBilling.subscriptions.create; if
absent, write a pending marker to the user row (e.g., set razorpaySubRequestId
to a generated UUID or razorpaySubId = "pending:<uuid>") with prisma.user.update
to reserve the intent, then call razorpayBilling.subscriptions.create(planId,
…), and finally replace the pending marker with subscription.id (and optional
short_url) via prisma.user.update; on remote failure clear the pending marker or
retry logic—reference razorpayBilling.subscriptions.create,
prisma.user.findUnique, prisma.user.update, and variables
planId/userId/subscription.id when implementing.

Comment thread src/lib/execution-gate.ts
Comment on lines 11 to 48
export async function checkExecutionLimit(userId: string): Promise<void> {
// Check if user has active Pro subscription
try {
const customer = await polarcliet.customers.getStateExternal({
externalId: userId,
})
if (customer.activeSubscriptions && customer.activeSubscriptions.length > 0) {
// Pro user — no limit
return
}
} catch {
// Customer not found in Polar = free user, continue to limit check
}

// Free user — check monthly execution count
const user = await prisma.user.findUnique({
where: { id: userId },
select: { executionCount: true, executionResetAt: true },
select: {
plan: true,
planStatus: true,
workflowRunsUsed: true,
workflowRunsReset: true,
},
})

if (!user) throw new TRPCError({ code: "UNAUTHORIZED" })

const plan = (user.plan || "FREE") as PlanKey
const limits = PLAN_LIMITS[plan] ?? PLAN_LIMITS.FREE

// Reset counter if it's a new month
const now = new Date()
const resetAt = new Date(user.executionResetAt)
const resetAt = new Date(user.workflowRunsReset)
const isNewMonth =
now.getMonth() !== resetAt.getMonth() ||
now.getFullYear() !== resetAt.getFullYear()

if (isNewMonth) {
await prisma.user.update({
where: { id: userId },
data: { executionCount: 0, executionResetAt: now },
data: { workflowRunsUsed: 0, workflowRunsReset: now },
})
return // Fresh month — allow execution
}

if (user.executionCount >= FREE_TIER_LIMIT) {
if (user.workflowRunsUsed >= limits.runs) {
throw new TRPCError({
code: "FORBIDDEN",
message: `Free tier limit reached. You have used ${FREE_TIER_LIMIT} executions this month. Upgrade to Pro for unlimited executions.`,
message: `You have used ${user.workflowRunsUsed}/${limits.runs} workflow runs this month on the ${limits.name} plan. Upgrade at https://nodebase.tech/pricing`,
})
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Reserve quota atomically.

The limit check and the increment happen in two separate operations, so concurrent workflow starts can all pass checkExecutionLimit() and then increment past the monthly cap. Move reset/check/increment into one transactional reservation step so each execution claims a slot exactly once.

Also applies to: 54-61

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

In `@src/lib/execution-gate.ts` around lines 11 - 48, checkExecutionLimit
currently reads user state and returns, allowing race conditions; change it to
perform the reset/check/increment in a single atomic DB operation so concurrent
starts cannot exceed the cap. Implement a transactional reservation using Prisma
(e.g., prisma.$transaction or a conditional prisma.user.update) that: 1)
computes now and limits from PLAN_LIMITS for the user's plan; 2) if
workflowRunsReset is in a previous month, atomically set workflowRunsUsed = 1
and workflowRunsReset = now; otherwise atomically increment workflowRunsUsed
only when workflowRunsUsed < limits.runs; 3) if the conditional update affects 0
rows, throw the same TRPCError FORBIDDEN with the existing message. Keep
identifiers: checkExecutionLimit, workflowRunsUsed, workflowRunsReset,
PLAN_LIMITS, and the TRPCError usage to locate and replace the current
multi-step logic with the single transactional reservation.

@Mayank-saraswal
Mayank-saraswal merged commit 15a04ec into main Jun 3, 2026
3 of 4 checks passed
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.

1 participant