Feat/razorpay billing - #76
Conversation
📝 WalkthroughWalkthroughThis 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
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 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 docstrings
🧪 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 |
There was a problem hiding this comment.
💡 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 * * *" }, |
There was a problem hiding this comment.
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", |
There was a problem hiding this comment.
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 👍 / 👎.
| razorpayEventId: | ||
| (event.account_id as string) || `${subId}-activated`, |
There was a problem hiding this comment.
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 👍 / 👎.
| emailVerifyToken: token, | ||
| emailVerifyExpiry: getTokenExpiry(), | ||
| emailVerifyAttempts: 0, | ||
| }, |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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 | 🟡 MinorTypo: Double apostrophe in text.
Line 212 contains
Don''twhich renders as "Don''t" instead of "Don't".🐛 Proposed fix
- Don''t have an account?{" "} + Don'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''t have an account?` with the correct `Don'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 | 🟠 MajorBug: "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-upinstead 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 | 🟠 MajorEscape user-controlled values before templating the email.
userNameis 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 | 🟠 MajorAvoid defaulting verification links to production.
When
NEXT_PUBLIC_APP_URLis unset, every verification email points athttps://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 | 🟠 MajorUse the server-returned checkout key.
src/server/routers/billing.router.ts:41-67already returnskeyIdalongsidesubscriptionId, 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 | 🟠 MajorMove manifest to app/manifest.json or explicitly wire it into the root layout.
public/manifest.jsonis currently unreferenced and has no effect. For a Next.js App Router project, the recommended approach is to place the manifest atapp/manifest.jsonso Next.js automatically serves it and handles it as a metadata file. Alternatively, if keeping it inpublic/, add the reference to the metadata object insrc/app/layout.tsxor 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 | 🟠 MajorAvoid 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 fromsessionStorage(with a generic fallback) instead ofuseSearchParams.🤖 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 | 🟠 MajorAdd
/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 | 🟠 MajorFail 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 | 🟠 MajorGate paid limits on
planStatus === "active".Line 24 trusts
user.planalone, 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 toFREEunless 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 | 🟠 MajorMake 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 | 🟠 MajorPropagate 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 | 🟠 MajorDon't return
VERIFICATION_SENTwhen 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 | 🟠 MajorThe 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-132already 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 | 🟠 MajorQuota enforcement is not atomic.
If multiple workers call
checkRunQuota()and thenincrementRunCount()concurrently, they can all pass the read check and pushworkflowRunsUsedpast 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 | 🟠 MajorReject misconfigured checkout on the server.
If
NEXT_PUBLIC_RAZORPAY_KEY_IDis unset, this returnskeyId: undefinedand 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 | 🟠 MajorQuota checks ignore subscription state.
checkRunQuota()keys only offuser.plan. If a webhook marks the accountpast_dueorcancelledbut 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 | 🟠 MajorCreate the user and account in one transaction.
If
prisma.account.create()fails afterprisma.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 | 🟠 MajorThis bypasses the resend throttle for unverified accounts.
This branch regenerates and sends a token on every signup retry, and it also resets
emailVerifyAttemptsto0. That makes the 3-attempt cap insrc/app/api/auth/resend-verification/route.tsineffective.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
razorpayEventIduses non-unique values, breaking webhook idempotency.
event.account_idis the merchant's account ID (same for all events), not a unique event identifier. The fallback${subId}-activatedis also not unique across webhook retries.Razorpay provides the unique event identifier in the
x-razorpay-event-idrequest 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 | 🟡 MinorMake the checkout script loader idempotent.
Two checkout attempts before the first script finishes loading will append two
checkout.jstags. 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 | 🟡 MinorHarden error parsing for non-JSON responses.
At Line 91, unconditionalres.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 | 🟡 MinorRespect 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 | 🟡 MinorClean 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 | 🟡 MinorValidate the parsed body before destructuring.
req.json()can returnnull, 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 | 🟡 MinorPeriod end calculation ignores Razorpay's authoritative data.
Calculating
currentPeriodEndasnow + 1 monthcan drift from the actual billing cycle. Forsubscription.chargedevents, Razorpay provides the subscription'scurrent_endtimestamp 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.
bcryptjsalready ships its own TypeScript declarations, and@types/bcryptjsis 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 explicittype="button"to toggle buttons.The interval toggle buttons are missing an explicit
typeattribute. While they're not inside a form, addingtype="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: Unusederrvariable 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
Suspensecomponent wrappingLoginContenthas nofallbackprop. While this may work, providing a fallback (even a minimal loading state) improves the user experience during the initial render whenuseSearchParamssuspends.🔧 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 uniquerazorpayEventId, but doesn't check for existing records or useupsert. 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.pausedandsubscription.resumeddon't createbillingEventrecords. 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, + }, + }) breakApply 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
signaturecontains 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
⛔ Files ignored due to path filters (1)
package-lock.jsonis excluded by!**/package-lock.json
📒 Files selected for processing (48)
next-sitemap.config.jspackage.jsonprisma/migrations/20260409154001_razorpay/migration.sqlprisma/migrations/20260409_add_razorpay_billing/migration.sqlprisma/schema.prismapublic/manifest.jsonscripts/create-razorpay-plans.tssrc/app/(auth)/check-email/page.tsxsrc/app/(auth)/resend-verification/page.tsxsrc/app/(auth)/verify-email/page.tsxsrc/app/(marketing)/pricing/page.tsxsrc/app/(marketing)/pricing/pricing-page.tsxsrc/app/api/auth/custom-signup/route.tssrc/app/api/auth/resend-verification/route.tssrc/app/api/auth/verify-email/route.tssrc/app/api/polar/webhook/route.tssrc/app/api/webhooks/razorpay-billing/route.tssrc/app/globals.csssrc/app/layout.tsxsrc/app/opengraph-image.tsxsrc/components/app-sidebar.tsxsrc/components/entity-components.tsxsrc/components/landing/marketing-page.tsxsrc/components/structured-data.tsxsrc/components/upgrade-modal.tsxsrc/components/upgrade-prompt.tsxsrc/components/usage-banner.tsxsrc/features/auth/components/login-form.tsxsrc/features/auth/components/register-form.tsxsrc/features/auth/components/subscriptions/hooks/use-subscription.tssrc/features/triggers/components/google-form-trigger/executor.tssrc/features/triggers/components/manual-trigger/executor.tssrc/features/triggers/components/stripe-trigger/executor.tssrc/features/workflows/hooks/use-workflows.tssrc/hooks/use-razorpay.tssrc/inngest/functions/schedule-poller.tssrc/lib/auth-client.tssrc/lib/auth.tssrc/lib/billing.tssrc/lib/email-verification.tssrc/lib/execution-gate.tssrc/lib/plan-limits.tssrc/lib/polar.tssrc/lib/razorpay-billing.tssrc/server/routers/billing.router.tssrc/server/routers/usage.router.tssrc/trpc/init.tssrc/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 * * *" }, |
There was a problem hiding this comment.
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.
| { 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.
| 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 | ||
| }, | ||
| }) |
There was a problem hiding this comment.
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.
| 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`, | ||
| }) | ||
| } | ||
| } |
There was a problem hiding this comment.
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.
Summary by CodeRabbit
Release Notes
New Features
Refactor
Chores