Skip to content

feat: update supercode-cli to version 0.1.93 and integrate Dodo Payments billing system: - #249

Merged
yashdev9274 merged 1 commit into
mainfrom
supercode-cli
Aug 2, 2026
Merged

feat: update supercode-cli to version 0.1.93 and integrate Dodo Payments billing system:#249
yashdev9274 merged 1 commit into
mainfrom
supercode-cli

Conversation

@yashdev9274

@yashdev9274 yashdev9274 commented Aug 2, 2026

Copy link
Copy Markdown
Owner

Description

  • Bumped version in package.json to 0.1.93.
  • Added Dodo Payments integration for subscription management, including checkout, billing status, and refund functionalities.
  • Implemented new API endpoints for billing plans, checkout sessions, and webhooks to handle payment events.
  • Introduced grandfathering logic for existing users and updated environment configuration for Dodo Payments keys.

Type of change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Documentation update
  • Refactor (no functional changes)

How Has This Been Tested?

Please describe the tests that you ran to verify your changes.

  • bun test passes
  • bun run typecheck passes
  • bun run lint passes (if applicable)

Checklist:

  • My code follows the project's style guidelines
  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective or that my feature works

Summary by CodeRabbit

  • New Features

    • Added a billing Studio for viewing plans, usage, credits, subscriptions, upgrades, cancellations, refunds, and billing portal access.
    • Added payment checkout and confirmation flows with regional pricing support.
    • Added the /upgrade CLI command for comparing plans and opening billing settings.
    • Added subscription-based model access, request limits, context handling, and credit tracking.
  • Improvements

    • Updated pricing plans, Spark limits, credits, FAQs, and regional Pro pricing.
    • Improved login redirects when returning to a requested page.

…nts billing system:

- Bumped version in package.json to 0.1.93.
- Added Dodo Payments integration for subscription management, including checkout, billing status, and refund functionalities.
- Implemented new API endpoints for billing plans, checkout sessions, and webhooks to handle payment events.
- Introduced grandfathering logic for existing users and updated environment configuration for Dodo Payments keys.
@vercel

vercel Bot commented Aug 2, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
supercli Ready Ready Preview Aug 2, 2026 12:53pm
supercli-client Ready Ready Preview Aug 2, 2026 12:53pm
supercli-docs Ready Ready Preview Aug 2, 2026 12:53pm

@greptile-apps greptile-apps 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.

Your trial has ended. Reactivate Greptile to resume code reviews.

@coderabbitai

coderabbitai Bot commented Aug 2, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

Added Dodo Payments billing across the CLI and web applications. The change includes seeded plans, billing APIs, a billing Studio, regional pricing, subscription enforcement, credit metering, webhook synchronization, and CLI upgrade flows.

Changes

Billing and plan enforcement

Layer / File(s) Summary
Billing data and configuration
apps/supercode-cli/server/prisma/*, apps/supercode-cli/server/package.json, apps/supercode-cli/server/.env.example, apps/supercode-cli/server/src/lib/auth.ts
Added subscription and model seed data, grandfathered-user migration, Dodo Payments configuration, Prisma seeding, and configurable GitHub OAuth redirects.
Billing API and payment events
apps/supercode-cli/server/src/api/billing/*, apps/supercode-cli/server/src/index.ts
Added plan, checkout, refund, status, cancellation, portal, and signed webhook routes. Mounted the routes under the billing API.
Billing studio and payment routing
apps/supercode-cli/client/app/studio/page.tsx, apps/supercode-cli/client/components/*, apps/supercode-cli/client/next.config.ts, apps/supercode-cli/client/vercel.json
Added the authenticated billing Studio, Dodo SDK loading, checkout and subscription actions, refund flows, and API rewrites.
Plan, model, context, and credit enforcement
apps/supercode-cli/server/src/lib/{subscription-check,request-counter,model-access,context-enforcer,credit-meter,plan-gate}.ts, apps/supercode-cli/server/src/index.ts
Added subscription lookup, request limits, model access checks, context enforcement, credit metering, and pre-provider plan gating.
CLI chat gating and upgrade command
apps/supercode-cli/server/src/cli/ai/chat/chat.ts, apps/supercode-cli/server/src/cli/commands/slashCommands/*
Applied plan checks to chat streams and added the authenticated /upgrade command.
Pricing page and terminal navigation
apps/web/app/(pages)/pricing/page.tsx, apps/web/.env.example, apps/web/package.json
Updated pricing, limits, regional pricing, plan comparisons, terminal Studio links, and web payment dependencies.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Customer
  participant Studio
  participant BillingAPI
  participant DodoPayments
  participant Database
  Customer->>Studio: Select plan
  Studio->>BillingAPI: Create checkout session
  BillingAPI->>Database: Load plan and customer
  BillingAPI->>DodoPayments: Create checkout
  DodoPayments-->>Customer: Complete payment
  DodoPayments->>BillingAPI: Send signed webhook
  BillingAPI->>Database: Update subscription and credits
  Studio->>BillingAPI: Request billing status
  BillingAPI-->>Studio: Return plan and usage data
Loading

Possibly related PRs

Poem

A rabbit hops through plans so bright,
Credits reset by webhook light.
Studio cards invite a cheer,
/upgrade brings the pathway near.
AI gates guard each token tight.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 21.74% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the version update and Dodo Payments billing integration, which are the primary changes in the pull request.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 2
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch supercode-cli

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint install failed. For unrecoverable errors, disable the tool in CodeRabbit configuration.


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.

@yashdev9274
yashdev9274 merged commit a6fd394 into main Aug 2, 2026
4 of 8 checks passed

@coderabbitai coderabbitai 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.

Actionable comments posted: 4

Note

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

🟠 Major comments (30)
apps/supercode-cli/server/src/cli/ai/chat/chat.ts-2249-2255 (1)

2249-2255: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Same duplicate plan-gate pattern, with a different control-flow style than the other two sites.

This site uses an early continue on denial, while lines 2063-2071 and 2167-2175 use if/else branching for the same outcome. The structural inconsistency across all three sites increases the risk that a future fix to one site (for example, the double-fetch issue) is missed in the others. See the consolidated comment.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/supercode-cli/server/src/cli/ai/chat/chat.ts` around lines 2249 - 2255,
Standardize the plan-gate handling in the chat flow around loadContextTokens,
checkPlanGate, and the denial output to match the if/else structure used at the
other two call sites. Preserve the existing denial message, footer rendering,
and successful execution behavior while removing the site-specific early
continue.
apps/supercode-cli/server/src/cli/ai/chat/chat.ts-2167-2175 (1)

2167-2175: 🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

Same duplicate plan-gate pattern as the other two call sites.

Identical structure to lines 2063-2071 and 2249-2255, with the same redundant getMessages fetch. See the consolidated comment.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/supercode-cli/server/src/cli/ai/chat/chat.ts` around lines 2167 - 2175,
Consolidate this plan-gate flow with the existing shared pattern used by the
neighboring call sites, removing the redundant getMessages fetch while
preserving loadContextTokens, checkPlanGate, gate-message output, and
streamAIResponse behavior. Update the surrounding chat handling in the current
flow to reuse the established implementation rather than duplicating it.
apps/supercode-cli/server/src/cli/commands/slashCommands/upgrade.ts-42-54 (1)

42-54: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Duplicate subscription-resolution logic can show a stale "current plan".

This block re-implements subscription-tier lookup instead of reusing getSubscriptionPlan from subscription-check.ts. The reused version applies an expiration-based downgrade to Spark when a paid plan's currentPeriodEnd has passed (subscription-check.ts, lines 39-66); this raw query does not. If a subscription's status field lags behind currentPeriodEnd (for example, until a webhook or cron job updates it), /upgrade shows the stale paid tier as "Current plan" while checkPlanGate already treats the user as downgraded. See the consolidated comment for a fix that reuses the canonical function.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/supercode-cli/server/src/cli/commands/slashCommands/upgrade.ts` around
lines 42 - 54, Replace the raw subscription lookup and derived currentTier logic
in the upgrade command with the canonical getSubscriptionPlan helper from
subscription-check.ts, preserving the expiration-based downgrade behavior used
by checkPlanGate. Continue deriving grandfathered status from the resolved
subscription data only if that data is returned by the helper, and remove the
duplicate plan-resolution query.
apps/supercode-cli/server/src/lib/context-enforcer.ts-27-33 (1)

27-33: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Truncate action reports a ceiling but cannot truncate anything.

This function receives only totalTokens: number, not the actual message list. When action === "truncate", it returns truncatedTokens: contextLimit and allowed: true, but nothing in this function's signature lets it cut the real conversation content. The doc comment at lines 3-6 says this "enforces" the context limit by truncation, but truncation only happens if a caller uses truncatedTokens to shorten the actual messages before the AI call.

Check plan-gate.ts, lines 60-66: it calls this with the default "truncate" action and never reads ctx.truncatedTokens. As a result, the context limit is effectively decorative for every plan tier. See the consolidated comment for a suggested fix spanning both files.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/supercode-cli/server/src/lib/context-enforcer.ts` around lines 27 - 33,
The truncate path in contextEnforcer is only reporting a token ceiling, while
plan-gate.ts ignores truncatedTokens and sends the full context. Update the
enforcement flow around contextEnforcer and the plan-gate.ts call site so the
truncate action actually limits the messages or tokenized context passed to the
AI request, while preserving the existing behavior for non-truncate actions.
apps/supercode-cli/server/src/lib/plan-gate.ts-60-66 (1)

60-66: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Context enforcement result is discarded — the limit is never actually applied.

enforceContextLimit is called with the default "truncate" action, which always returns allowed: true for over-limit requests (see context-enforcer.ts, lines 27-33). This block only checks ctx.allowed, so it never reads ctx.truncatedTokens or ctx.message. The caller in chat.ts (lines 2063-2071, 2167-2175, 2249-2255) does not truncate the message list either — it proceeds straight to streamAIResponse with the full, untrimmed conversation regardless of plan.contextLimit.

The net effect: every tier's context limit (16K/32K/128K/1M) is checked but never enforced for the default action. Only action: "block" would actually stop a request, and nothing currently passes that action. See the consolidated comment for a suggested fix spanning context-enforcer.ts, this file, and chat.ts.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/supercode-cli/server/src/lib/plan-gate.ts` around lines 60 - 66, Update
enforceContextLimit and the plan-gate context enforcement flow so an over-limit
request is actually constrained rather than treated as allowed without action.
Propagate ctx.truncatedTokens (or equivalent truncation metadata) through the
gate result, then update the chat.ts call sites before streamAIResponse to
truncate the conversation accordingly; preserve blocking behavior for explicitly
blocked requests and keep under-limit requests unchanged.
apps/supercode-cli/server/src/cli/ai/chat/chat.ts-2063-2071 (1)

2063-2071: 🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

Duplicate plan-gate pattern with a redundant message fetch.

This block repeats the same loadContextTokenscheckPlanGate → branch pattern found at lines 2167-2175 and 2249-2255. loadContextTokens (lines 27-34) fetches the conversation transcript via getMessages, and streamAIResponse (line 298) fetches it again independently for the same turn — doubling the network round trip to the conversation API for every gated message. See the consolidated comment for a combined fix across all three sites.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/supercode-cli/server/src/cli/ai/chat/chat.ts` around lines 2063 - 2071,
Consolidate the repeated plan-gate flow around loadContextTokens, checkPlanGate,
and streamAIResponse at all three call sites so each turn fetches the
conversation transcript only once. Reuse the loaded messages/context when
streaming the response, updating streamAIResponse’s inputs as needed, while
preserving the existing denied and aborted-response behavior.
apps/supercode-cli/server/src/lib/request-counter.ts-3-49 (1)

3-49: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Fail-open on DB error defeats the monthly request cap.

If prisma.usageEvent.aggregate throws, getMonthlyRequestCount returns 0 (line 20). checkRequestLimit then reports used: 0, so the hard request-limit gate passes regardless of actual usage during any transient DB failure. This lets requests bypass the cap exactly when the enforcement is least trustworthy.

Compare this to model-access.ts's isModelAllowedForTier, which fails closed (returns false) on a similar DB error. Make checkRequestLimit fail closed too, since the request cap is documented as a hard gate.

🐛 Proposed fix to fail closed on DB error
 export async function getMonthlyRequestCount(userId: string): Promise<number> {
   const startOfMonth = new Date()
   startOfMonth.setDate(1)
   startOfMonth.setHours(0, 0, 0, 0)

-  try {
-    const result = await prisma.usageEvent.aggregate({
-      where: {
-        userId,
-        createdAt: { gte: startOfMonth },
-      },
-      _count: { id: true },
-    })
-
-    return result._count.id
-  } catch (error) {
-    console.error("[request-counter] Failed to count requests:", error)
-    return 0
-  }
+  const result = await prisma.usageEvent.aggregate({
+    where: {
+      userId,
+      createdAt: { gte: startOfMonth },
+    },
+    _count: { id: true },
+  })
+
+  return result._count.id
 }

 export async function checkRequestLimit(
   userId: string,
   requestLimit: number,
 ): Promise<{
   allowed: boolean
   used: number
   limit: number
   message?: string
 }> {
-  const used = await getMonthlyRequestCount(userId)
+  let used: number
+  try {
+    used = await getMonthlyRequestCount(userId)
+  } catch (error) {
+    console.error("[request-counter] Failed to count requests:", error)
+    return {
+      allowed: false,
+      used: requestLimit,
+      limit: requestLimit,
+      message: "Unable to verify your usage right now. Please try again shortly.",
+    }
+  }

   if (used >= requestLimit) {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/supercode-cli/server/src/lib/request-counter.ts` around lines 3 - 49,
Update getMonthlyRequestCount and checkRequestLimit so database aggregation
failures fail closed instead of returning a zero count that permits requests.
Preserve normal usage counting, but propagate or explicitly represent the error
so checkRequestLimit returns allowed: false on DB failure, following the
fail-closed behavior of isModelAllowedForTier.
apps/supercode-cli/server/src/lib/subscription-check.ts-39-66 (1)

39-66: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Fallback branch mislabels and hardcodes plan data.

When a paid plan expires and the code falls back to a Spark subscription, line 61 sets isGrandfathered: true unconditionally. This fallback subscription is not necessarily grandfathered — it only means the paid plan expired and a separate Spark row exists. Downstream consumers of PlanInfo.isGrandfathered (grandfathering is a named PR objective) get the wrong signal for these users.

Line 59 also hardcodes modelAccess: "open" instead of reading fallback.plan.modelAccess. If the Spark plan's modelAccess ever changes in configuration, this fallback path silently diverges from the actual plan record.

Separately, if no dedicated "spark" subscription row exists for the user (line 45-52 lookup fails), the function returns null at line 65. plan-gate.ts then shows "You don't have an active subscription" to a user whose paid plan just expired, which is misleading.

🐛 Proposed fix for the fallback branch
       if (fallback?.plan) {
+        const fallbackGrandfathered = isGrandfathered(fallback)
         return {
           tier: "spark",
           name: fallback.plan.name,
           requestLimit: fallback.plan.requestLimit,
           contextLimit: fallback.plan.contextLimit,
-          modelAccess: "open",
+          modelAccess: fallback.plan.modelAccess as PlanInfo["modelAccess"],
           creditAmountCents: fallback.plan.creditAmountCents,
-          isGrandfathered: true,
+          isGrandfathered: fallbackGrandfathered,
           currentPeriodEnd: fallback.currentPeriodEnd,
         }
       }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/supercode-cli/server/src/lib/subscription-check.ts` around lines 39 -
66, Update the expired-plan fallback in the subscription-check function to
derive modelAccess from fallback.plan.modelAccess and set isGrandfathered from
the fallback subscription’s actual grandfathering state rather than hardcoding
true. When no active Spark fallback exists, return the established Spark plan
representation or other intended expired-plan fallback instead of null, so
plan-gate.ts does not report that the user lacks any active subscription.
apps/supercode-cli/server/src/lib/credit-meter.ts-40-66 (1)

40-66: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Race condition can drive credit balance negative.

Lines 47-50 read the balance, line 52 checks it against cost, and lines 56-59 apply the decrement as a separate operation. Two concurrent requests for the same user (for example, two in-flight chat turns) can both read a balance that covers cost, both pass the check, and both decrement — leaving balanceCents negative. Guard the decrement with a conditional update so it only applies when the balance still covers the cost at write time.

🔒️ Proposed fix using a conditional update
     const balance = await prisma.creditBalance.findFirst({
       where: { userId },
       orderBy: { updatedAt: "desc" },
     })

-    if (!balance || balance.balanceCents < cost) {
-      return { deducted: false, remainingCents: balance?.balanceCents ?? 0 }
-    }
-
-    await prisma.creditBalance.update({
-      where: { userId_planId: { userId: balance.userId, planId: balance.planId } },
-      data: { balanceCents: { decrement: cost } },
-    })
-
-    return { deducted: true, remainingCents: balance.balanceCents - cost }
+    if (!balance) {
+      return { deducted: false, remainingCents: 0 }
+    }
+
+    const result = await prisma.creditBalance.updateMany({
+      where: {
+        userId: balance.userId,
+        planId: balance.planId,
+        balanceCents: { gte: cost },
+      },
+      data: { balanceCents: { decrement: cost } },
+    })
+
+    if (result.count === 0) {
+      return { deducted: false, remainingCents: balance.balanceCents }
+    }
+
+    return { deducted: true, remainingCents: balance.balanceCents - cost }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/supercode-cli/server/src/lib/credit-meter.ts` around lines 40 - 66,
Update deductCredits to make the credit decrement atomic: replace the separate
balance check and unconditional update in the prisma.creditBalance operation
with a conditional update that requires balanceCents to remain at least cost at
write time. Preserve the existing insufficient-balance return and return the
actual post-deduction balance, handling a failed conditional update as deducted:
false without allowing the balance to become negative.
apps/supercode-cli/server/src/lib/model-access.ts-28-43 (1)

28-43: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Substring matching on model slugs risks incorrect tier gating.

modelSlug.includes(m.slug) at line 35 matches any model whose slug is a substring of the requested modelSlug, not just a prefixed variant. Short slugs (for example "hy3" used in credit-meter.ts's cost table) can accidentally match unrelated model identifiers that happen to contain that substring. Since Array.prototype.find checks each candidate's combined ===/includes condition in array order, a coincidental substring match can win over the intended exact match, depending on Model table row order.

This directly feeds isModelAllowedForTier, so a wrong match changes what a user tier is allowed to run. Restrict the match to an exact slug or a provider/slug suffix match instead of an unanchored substring check.

🔒️ Proposed fix for slug matching
     const models = await ensureCache()
-    const model = models.find(
-      (m) => m.slug === modelSlug || modelSlug.includes(m.slug),
-    )
+    const model = models.find(
+      (m) => m.slug === modelSlug || modelSlug.endsWith(`/${m.slug}`),
+    )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/supercode-cli/server/src/lib/model-access.ts` around lines 28 - 43,
Update the model lookup in isModelAllowedForTier to match only an exact model
slug or a provider/slug-form suffix match, replacing the unanchored
modelSlug.includes(m.slug) condition. Preserve the existing tierIndex comparison
and false-on-missing/error behavior.
apps/web/app/(pages)/pricing/page.tsx-82-91 (1)

82-91: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Pro (India) monthly price disagrees with this file's own FAQ and with the seeded plan price.

This card shows ₹649 for Pro Monthly (India), but the FAQ entry at line 165 in this same file says "₹749/month", and apps/supercode-cli/server/prisma/seed.ts seeds the same plan at priceCents: 65900 (₹659). Three different numbers for one plan. See the consolidated comment for the full list of sites to reconcile.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/app/`(pages)/pricing/page.tsx around lines 82 - 91, The Pro monthly
India price in the pricing card is inconsistent with the FAQ and seeded plan
price. Update the Pro monthly Indian value in the pricing configuration to the
canonical price used for this plan, and ensure the corresponding FAQ entry
remains consistent with that same value.
apps/web/app/(pages)/pricing/page.tsx-161-165 (1)

161-165: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

FAQ Pro (India) monthly price (₹749) doesn't match the pricing card (₹649).

The FAQ text here states Indian users pay ₹749/month, while the Pro tier card above (line 82) displays ₹649/month for the same plan. Fix one of the two so the displayed price is consistent. See the consolidated comment for the complete set of mismatched sites, including seed.ts.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/app/`(pages)/pricing/page.tsx around lines 161 - 165, Make the
Indian Pro monthly price consistent between the FAQ answer in the pricing page
and the Pro pricing card, using the intended ₹649 or ₹749 value consistently.
Also update the corresponding Pro price in seed.ts referenced by the review so
all displayed and seeded pricing values match.
apps/supercode-cli/server/prisma/seed.ts-1-1 (1)

1-1: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Reconcile Pro (India) pricing across the seed data and the pricing page.

The Pro (India) plan price is stated with three different values across this PR: the pricing card, its own FAQ text, and the seeded plan price that presumably backs the actual Dodo product/checkout amount. Pick one true value per billing period and update every site to match, otherwise customers will see one price on the marketing page and be billed a different amount (or see conflicting numbers within the same page).

  • apps/supercode-cli/server/prisma/seed.ts#L72-86: priceCents: 65900 seeds Pro Monthly (India) at ₹659; align this with whichever of ₹649 or ₹749 is the intended monthly price.
  • apps/supercode-cli/server/prisma/seed.ts#L102-116: priceCents: 770000 seeds Pro Yearly (India) at ₹7,700, while the pricing page shows ₹8,300/year; align this value with the page or update the page.
  • apps/web/app/(pages)/pricing/page.tsx#L82-91: the Pro tier card shows ₹649/month; make this match both the FAQ text and the seeded plan price.
  • apps/web/app/(pages)/pricing/page.tsx#L161-165: the FAQ text shows ₹749/month for the same plan; make this match the card and the seeded plan price.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/supercode-cli/server/prisma/seed.ts` at line 1, Reconcile the Pro India
pricing values across the seed definitions in the Prisma seed flow and the
pricing page’s Pro tier card and FAQ. Choose one intended monthly price and one
intended yearly price, then update the corresponding priceCents values and
displayed ₹ amounts so all checkout, card, and FAQ values match.
apps/supercode-cli/server/prisma/seed.ts-72-86 (1)

72-86: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Pro (India) plan prices don't match the marketing pricing page.

priceCents: 65900 (line 78) charges ₹659/month, and priceCents: 770000 (line 108) charges ₹7,700/year for Pro (India). apps/web/app/(pages)/pricing/page.tsx advertises ₹649/month and ₹8,300/year for the same plan (and its own FAQ text says ₹749/month). See the consolidated comment for the full breakdown and the sites that need to agree.

Also applies to: 102-116

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/supercode-cli/server/prisma/seed.ts` around lines 72 - 86, Align the Pro
India pricing data in the seed plan definitions with the intended marketing
prices: update the monthly and annual Pro India entries, identified by
dodoProductId values DODO_PRODUCT_IDS.proMonthlyIn and
DODO_PRODUCT_IDS.proYearlyIn, and ensure the corresponding pricing page values
and FAQ text use the same agreed amounts. Resolve the conflicting ₹649/₹749
monthly and ₹7,700/₹8,300 annual values consistently across these symbols.
apps/supercode-cli/server/prisma/add-grandfather-users.ts-33-66 (1)

33-66: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Wrap subscription creation and credit-balance upsert in one transaction.

If creditBalance.upsert (line 47) fails after subscription.create (line 36) succeeds, the user ends up with an active subscription but no credits. The users.findMany filter on line 24-28 excludes users who already have a subscription, so a re-run of this script will not retry that user. The user is left permanently under-provisioned until someone manually inspects the database.

Wrap both writes in prisma.$transaction so a failure in either step rolls back both, keeping the user eligible for a clean retry.

🔧 Suggested fix
-    await prisma.subscription.create({
-      data: {
-        userId: user.id,
-        planId: sparkPlan.id,
-        dodoSubscriptionId: dodoSubId,
-        status: "active",
-        metadata: { grandfathered: true },
-      },
-    })
-
-    const periodEnd = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000)
-    await prisma.creditBalance.upsert({
-      where: {
-        userId_planId: { userId: user.id, planId: sparkPlan.id },
-      },
-      update: {
-        balanceCents: 500,
-        totalCredits: 500,
-        resetAt: periodEnd,
-      },
-      create: {
-        userId: user.id,
-        planId: sparkPlan.id,
-        balanceCents: 500,
-        totalCredits: 500,
-        resetAt: periodEnd,
-      },
-    })
+    const periodEnd = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000)
+    await prisma.$transaction([
+      prisma.subscription.create({
+        data: {
+          userId: user.id,
+          planId: sparkPlan.id,
+          dodoSubscriptionId: dodoSubId,
+          status: "active",
+          metadata: { grandfathered: true },
+        },
+      }),
+      prisma.creditBalance.upsert({
+        where: {
+          userId_planId: { userId: user.id, planId: sparkPlan.id },
+        },
+        update: {
+          balanceCents: 500,
+          totalCredits: 500,
+          resetAt: periodEnd,
+        },
+        create: {
+          userId: user.id,
+          planId: sparkPlan.id,
+          balanceCents: 500,
+          totalCredits: 500,
+          resetAt: periodEnd,
+        },
+      }),
+    ])
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/supercode-cli/server/prisma/add-grandfather-users.ts` around lines 33 -
66, Wrap the subscription creation and credit-balance upsert inside a single
prisma.$transaction for each user in the grandfathering loop. Use the
transaction client for both writes so either both records persist or both roll
back, while preserving the existing data and count increment behavior.
apps/supercode-cli/client/components/auth/login-form.tsx-20-26 (1)

20-26: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Validate redirect before navigating to avoid an open redirect.

router.replace(get("redirect")) accepts attacker-controlled values directly, so /sign-in?redirect=https://evil.example can log a trusted user out or redirect them off-site after sign-in. Reject protocol URLs and restrict redirect to same-origin internal routes before using it.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/supercode-cli/client/components/auth/login-form.tsx` around lines 20 -
26, Validate the redirect value in the login useEffect before passing it to
router.replace: reject protocol URLs and accept only same-origin internal
routes, preserving the "/" fallback for missing or invalid values. Keep the
existing navigation flow for validated redirects and update the logic around the
redirect variable.
apps/supercode-cli/server/package.json-25-27 (1)

25-27: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Move Prisma seed config into prisma.config.ts.

This package uses Prisma v7, where package.json#prisma.seed is no longer read; prisma.config.ts is the source of truth and currently has no migrations.seed, so bun run -with prisma db seed will not execute prisma/seed.ts.

Suggested fix
-  "prisma": {
-    "seed": "bun run prisma/seed.ts"
-  },
// prisma.config.ts
import { defineConfig } from "prisma/config"

export default defineConfig({
  schema: "prisma/schema.prisma",
  migrations: {
    seed: "bun prisma/seed.ts",
  },
})
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/supercode-cli/server/package.json` around lines 25 - 27, Remove the
obsolete package.json prisma.seed configuration and update prisma.config.ts to
include migrations.seed pointing to prisma/seed.ts, preserving the existing
schema configuration and using the project’s Bun command convention.
apps/supercode-cli/server/src/api/billing/webhook.ts-224-234 (1)

224-234: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Do not write an unmapped provider status to the database.

Line 227 falls back to data.status when the status is not in statusMap. The rest of the codebase compares against a fixed set of values, for example status: { in: ["active", "trialing"] } in apps/supercode-cli/server/src/api/billing/status.ts (Line 31). An unmapped provider status writes a value that no query matches, which silently removes the user's access.

Log a warning and keep the current status when the mapping is missing.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/supercode-cli/server/src/api/billing/webhook.ts` around lines 224 - 234,
Update the subscription update flow around statusMap and
prisma.subscription.updateMany so unmapped provider statuses are not persisted:
detect a missing mapping, log a warning, and omit the status update so each
subscription keeps its current status. Preserve mapped statuses and the existing
updates to other fields.
apps/supercode-cli/server/src/api/billing/webhook.ts-128-164 (1)

128-164: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Cancel the other subscriptions after the upsert succeeds, and run both writes in one transaction.

Lines 130-137 cancel every other active subscription. Lines 143-164 then upsert the new one. The two writes are independent. If the upsert throws, the route returns HTTP 400, and the user is left with no active subscription until Dodo retries. A retry can also fail for a persistent reason, such as a missing user row, which makes the loss permanent.

Wrap both writes in prisma.$transaction, and perform the upsert first.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/supercode-cli/server/src/api/billing/webhook.ts` around lines 128 - 164,
Wrap the subscription writes in prisma.$transaction, moving the
prisma.subscription.upsert before the updateMany cancellation. Keep the existing
upsert fields and cancellation filter unchanged, and ensure both operations
execute atomically so cancellation only occurs after the upsert succeeds.
apps/supercode-cli/server/src/api/billing/plans.ts-6-16 (1)

6-16: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Derive isGrandfathered on the server, not from the query string.

Line 8 reads the flag from the client. apps/supercode-cli/client/app/studio/page.tsx Line 287 hardcodes isGrandfathered=true, so every caller receives the free Spark plan. The filter on Line 15 never runs in practice, and the documented rule on Lines 12-13 is not enforced.

Accept an authenticated userId, then look up whether the user holds a subscription whose plan tier is spark. Use that result for the filter. The client also sends a variant parameter that this handler ignores; either use it or remove it from the client call.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/supercode-cli/server/src/api/billing/plans.ts` around lines 6 - 16,
Update the GET handler around isGrandfathered to derive the value server-side
from an authenticated userId by checking whether the user has a subscription
with a spark-tier plan, rather than trusting req.query.isGrandfathered. Preserve
the existing plan filter based on that lookup, and reconcile the client’s
variant parameter by either using it in this handler or removing it from the
client request.
apps/supercode-cli/server/src/api/billing/webhook.ts-100-105 (1)

100-105: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Signal a failure when the plan lookup returns no row.

The handler logs a warning and returns. The route then responds HTTP 200 with received: true, so Dodo does not retry. A paid subscription for an unmapped product_id is dropped, and no local subscription is ever created. The user pays and receives no access.

Throw here so the route returns HTTP 400 and Dodo retries. Add an alert on this log line so an operator can add the missing plan mapping.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/supercode-cli/server/src/api/billing/webhook.ts` around lines 100 - 105,
Update the missing-plan branch in the subscription.active webhook handler to
alert operators and throw an error instead of returning after the warning,
ensuring the route responds with HTTP 400 and Dodo retries when no plan matches
data.product_id.
apps/supercode-cli/server/src/api/billing/webhook.ts-360-391 (1)

360-391: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Add event idempotency before you dispatch a handler.

Dodo retries webhooks, and providers deliver events more than once. This route processes every delivery. The effects are not all idempotent:

  • handleSubscriptionRenewed calls resetCredits, which sets balanceCents back to the full plan amount. A replayed subscription.renewed event refills credits that the user already consumed.
  • handleSubscriptionActive re-runs the cancel-then-upsert sequence.

Persist each event.id in a processed-events table with a unique constraint. Skip the handler when the insert conflicts. Also consider recording delivery failures so an operator can reconcile them.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/supercode-cli/server/src/api/billing/webhook.ts` around lines 360 - 391,
Persist each webhook event.id in a processed-events store with a unique
constraint before dispatching through EVENT_HANDLERS in the router.post handler.
Treat a unique-conflict insert as an already-processed delivery and return a
successful response without invoking the handler; record processing failures
when appropriate so failed deliveries remain reconcilable.
apps/supercode-cli/server/src/index.ts-334-339 (1)

334-339: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Pass the real token count to checkPlanGate, and avoid the hardcoded model default.

Two points:

  1. checkPlanGate accepts opts.totalTokens and enforces the plan context limit only when that value is present (apps/supercode-cli/server/src/lib/plan-gate.ts Lines 63-69). This call omits it, so context enforcement never runs on this path. Pass the computed token count for the request.
  2. The literal "deepseek-v4-flash" duplicates a default that belongs with the model configuration. Extract it to a shared constant so the gate and the provider dispatch agree on the same fallback model. If they disagree, the gate authorizes one model while the provider runs another.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/supercode-cli/server/src/index.ts` around lines 334 - 339, Update the
request flow around checkPlanGate to pass the computed request token count
through opts.totalTokens so context limits are enforced. Replace the inline
"deepseek-v4-flash" fallback with a shared default-model constant defined
alongside the model configuration, and reuse that constant for both plan gating
and provider dispatch.
apps/supercode-cli/server/src/api/billing/refund.ts-45-57 (1)

45-57: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Fix the fallback for a null currentPeriodStart.

Line 46 falls back to new Date() when currentPeriodStart is null. The count on Line 47 then covers a zero-length window and always returns 0. queriesUnderLimit is then always true, so the query threshold is not enforced for any subscription that lacks a period start.

Use the subscription createdAt as the fallback window start.

🐛 Proposed fix
-    const startOfPeriod = subscription.currentPeriodStart ?? new Date()
+    const startOfPeriod = subscription.currentPeriodStart ?? subscription.createdAt
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/supercode-cli/server/src/api/billing/refund.ts` around lines 45 - 57,
Update the startOfPeriod fallback in the refund eligibility flow to use the
subscription’s createdAt value instead of new Date(). Preserve
currentPeriodStart when present so queriesUsed and queriesUnderLimit evaluate
the correct usage window.
apps/supercode-cli/server/src/api/billing/status.ts-124-127 (1)

124-127: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Return an error status when the status query fails.

The catch block responds with HTTP 200 and a null payload. The Studio client checks statusRes.ok (apps/supercode-cli/client/app/studio/page.tsx Line 291), so it treats a database outage as a successful response that reports no subscription. A paying user then sees "No active plan" and the free-tier defaults, and the error card never appears.

🐛 Proposed fix
   } catch (error) {
     console.error("[billing/status] Failed to fetch subscription status:", error)
-    res.json({ subscription: null, plan: null, creditBalance: null, requestsUsed: 0 })
+    res.status(500).json({ error: "Failed to fetch subscription status" })
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/supercode-cli/server/src/api/billing/status.ts` around lines 124 - 127,
Update the error path in the billing status handler’s catch block to return a
non-2xx HTTP status when fetching subscription status fails, while preserving
the existing error logging and response payload. Ensure the Studio client’s
statusRes.ok check receives a failure result so it displays the error state
instead of treating the user as unsubscribed.
apps/supercode-cli/server/src/api/billing/refund.ts-106-126 (1)

106-126: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

The code grants the free Spark plan to every refunding user, not only to grandfathered users.

The comment on Lines 106-107 states the intent: fall back to grandfathered Spark only if the user has one. The code does not check that. It looks up the Spark plan globally (Lines 108-110) and creates a new active Spark subscription for any user who completes a refund. A user who never held a grandfathered plan receives permanent free access after paying $1 and refunding it.

Check for a prior Spark subscription belonging to this user before you create one.

🐛 Proposed fix
     const grandfatheredPlan = await prisma.plan.findFirst({
       where: { tier: "spark", active: true },
     })
     if (grandfatheredPlan) {
       const existingSpark = await prisma.subscription.findFirst({
-        where: { userId, planId: grandfatheredPlan.id, status: "active" },
+        where: { userId, planId: grandfatheredPlan.id },
+        orderBy: { createdAt: "desc" },
       })
-      if (!existingSpark) {
-        await prisma.subscription.create({
-          data: {
-            userId,
-            planId: grandfatheredPlan.id,
-            dodoSubscriptionId: `grandfathered-${userId}-${Date.now()}`,
-            status: "active",
-            metadata: { grandfathered: true, refunded: true },
-          },
-        })
+      // Only restore the free plan for users who already held it.
+      if (existingSpark && existingSpark.status !== "active") {
+        await prisma.subscription.update({
+          where: { id: existingSpark.id },
+          data: { status: "active" },
+        })
       }
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/supercode-cli/server/src/api/billing/refund.ts` around lines 106 - 126,
Update the grandfathered Spark fallback around grandfatheredPlan and
existingSpark so it only creates a subscription when the refunding user has a
prior Spark subscription. Query the user’s subscription history, require a
matching previous Spark subscription before entering the creation path, and
preserve the existing active-subscription reuse behavior.
apps/supercode-cli/server/src/api/billing/checkout.ts-56-67 (1)

56-67: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Reject the request when the user record does not exist.

Line 58 loads the user, but the code continues when user is null. Line 67 then sends a synthetic email ${userId}@supercode.local`` to Dodo. This creates a payment customer with an unreachable email address, and it creates a checkout session for a user that does not exist in the database. Receipts and dunning email fail silently.

🐛 Proposed fix
     // Create or reuse a Dodo customer for this user
     let dodoCustomerId: string | null = null
     const user = await prisma.user.findUnique({ where: { id: userId } })
+    if (!user) {
+      res.status(404).json({ error: "User not found" })
+      return
+    }
-    if (user?.dodoCustomerId) {
+    if (user.dodoCustomerId) {
       dodoCustomerId = user.dodoCustomerId
     }
 
     const resolvedVariant = variant ?? plan.variant
 
     const customer: Record<string, unknown> = dodoCustomerId
       ? { customer_id: dodoCustomerId }
-      : { email: user?.email ?? `${userId}`@supercode.local``, name: user?.name ?? "Supercode User" }
+      : { email: user.email, name: user.name ?? "Supercode User" }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/supercode-cli/server/src/api/billing/checkout.ts` around lines 56 - 67,
Validate the result of prisma.user.findUnique in the checkout flow before
deriving dodoCustomerId, resolvedVariant, or customer. If no user is found,
reject the request using the endpoint’s established error response or exception
pattern, and remove the synthetic fallback email path; existing behavior for
valid users must remain unchanged.
apps/supercode-cli/server/src/api/billing/status.ts-92-104 (1)

92-104: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

The grandfathered fallback payload omits fields the client requires.

The SubscriptionInfo type in apps/supercode-cli/client/app/studio/page.tsx (Lines 58-66) declares currentPeriodStart, currentPeriodEnd, trialEndsAt, cancelAtPeriodEnd, and isGrandfathered. This branch sends only id, status, and metadata.

The direct consequence: isGrandfathered is undefined, so the client evaluates subscription?.isGrandfathered ?? false (Line 311) as false. A user on the grandfathered Spark fallback does not receive the "Grandfathered" badge, and the cancel dialog shows the wrong warning text.

🐛 Proposed fix
         res.json({
           subscription: {
             id: fallback.id,
             status: fallback.status,
+            currentPeriodStart: fallback.currentPeriodStart,
+            currentPeriodEnd: fallback.currentPeriodEnd,
+            trialEndsAt: fallback.trialEndsAt,
+            cancelAtPeriodEnd: fallback.cancelAtPeriodEnd,
             metadata: fallback.metadata,
+            isGrandfathered: isGrandfathered(fallback),
           },
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/supercode-cli/server/src/api/billing/status.ts` around lines 92 - 104,
Update the fallback response in the billing status handler to include all
SubscriptionInfo fields required by the client: currentPeriodStart,
currentPeriodEnd, trialEndsAt, cancelAtPeriodEnd, and isGrandfathered. Populate
them from the fallback where available, and explicitly mark isGrandfathered true
so the grandfathered badge and cancellation warning render correctly.
apps/supercode-cli/server/src/api/billing/refund.ts-71-104 (1)

71-104: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Record refund state before updating the subscription.

The refund request and the subscription update are separate operations, so a stopped request can refund the payment while leaving the subscription active. Use a database transaction for the Dodo refund result and subscription update, and add a stable idempotency key or persisted refund id before retrying/refunding the same payment.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/supercode-cli/server/src/api/billing/refund.ts` around lines 71 - 104,
Update the refund flow around the Dodo refund logic and the subscription.update
call to persist the refund outcome and cancellation atomically in a database
transaction. Add a stable idempotency key or persisted refund identifier tied to
the subscription/payment before retrying or creating the refund, and ensure
retries cannot issue duplicate refunds while leaving the subscription active.
apps/supercode-cli/server/src/api/billing/webhook.ts-9-14 (1)

9-14: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Reject the webhook when DODO_PAYMENTS_WEBHOOK_KEY is not set.

getDodo still creates a client when only DODO_PAYMENTS_API_KEY is present. dodo.webhooks.unwrap(...) relies on the webhook signing key, so require it before processing webhook events.

🔒️ Proposed fix
 function getDodo(): DodoPayments | null {
   const key = process.env.DODO_PAYMENTS_API_KEY
   const webhookKey = process.env.DODO_PAYMENTS_WEBHOOK_KEY
-  if (!key) return null
+  if (!key || !webbookKey) return null
   return new DodoPayments({ bearerToken: key, webhookKey })
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/supercode-cli/server/src/api/billing/webhook.ts` around lines 9 - 14,
Update getDodo to return null unless both DODO_PAYMENTS_API_KEY and
DODO_PAYMENTS_WEBHOOK_KEY are configured, and only instantiate DodoPayments
after both values are validated so webhook events cannot be processed without a
signing key.
🟡 Minor comments (2)
apps/supercode-cli/client/app/layout.tsx-6-6 (1)

6-6: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Document NEXT_PUBLIC_DODO_PUBLISHABLE_KEY for this app.

DodoPaymentsScript reads process.env.NEXT_PUBLIC_DODO_PUBLISHABLE_KEY, but the client environment configuration documentation does not include it. Add NEXT_PUBLIC_DODO_PUBLISHABLE_KEY to the app’s client .env.example or equivalent local setup docs so fresh environments load the SDK with the required publishable key.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/supercode-cli/client/app/layout.tsx` at line 6, Document
NEXT_PUBLIC_DODO_PUBLISHABLE_KEY in the app’s client environment example or
equivalent local setup documentation, using the existing environment variable
format and placement conventions so fresh environments provide the publishable
key consumed by DodoPaymentsScript.
apps/supercode-cli/client/app/studio/page.tsx-177-193 (1)

177-193: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Guard against a zero limit.

Line 178 divides by limit. If currentPlan.requestLimit is 0, for example on an unlimited plan, pct becomes NaN. The label then reads "(NaN%)", and the bar width is NaN%.

🐛 Proposed fix
-  const pct = Math.min(100, Math.round((used / limit) * 100))
+  const pct = limit > 0 ? Math.min(100, Math.round((used / limit) * 100)) : 0
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/supercode-cli/client/app/studio/page.tsx` around lines 177 - 193, Update
PlanLimitBar to handle a zero limit before calculating pct, ensuring both the
displayed percentage and progress-bar width remain valid rather than producing
NaN. Preserve the existing capped percentage and color behavior for positive
limits.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: b4e7ab82-747b-4b6f-b245-f160b6758b8d

📥 Commits

Reviewing files that changed from the base of the PR and between bdeb6d4 and c55bcad.

⛔ Files ignored due to path filters (1)
  • bun.lock is excluded by !**/*.lock
📒 Files selected for processing (29)
  • apps/supercode-cli/client/app/layout.tsx
  • apps/supercode-cli/client/app/studio/page.tsx
  • apps/supercode-cli/client/components/DodoPaymentsScript.tsx
  • apps/supercode-cli/client/components/auth/login-form.tsx
  • apps/supercode-cli/client/next.config.ts
  • apps/supercode-cli/client/vercel.json
  • apps/supercode-cli/server/.env.example
  • apps/supercode-cli/server/package.json
  • apps/supercode-cli/server/prisma/add-grandfather-users.ts
  • apps/supercode-cli/server/prisma/seed.ts
  • apps/supercode-cli/server/src/api/billing/checkout.ts
  • apps/supercode-cli/server/src/api/billing/plans.ts
  • apps/supercode-cli/server/src/api/billing/refund.ts
  • apps/supercode-cli/server/src/api/billing/status.ts
  • apps/supercode-cli/server/src/api/billing/webhook.ts
  • apps/supercode-cli/server/src/cli/ai/chat/chat.ts
  • apps/supercode-cli/server/src/cli/commands/slashCommands/index.ts
  • apps/supercode-cli/server/src/cli/commands/slashCommands/upgrade.ts
  • apps/supercode-cli/server/src/index.ts
  • apps/supercode-cli/server/src/lib/auth.ts
  • apps/supercode-cli/server/src/lib/context-enforcer.ts
  • apps/supercode-cli/server/src/lib/credit-meter.ts
  • apps/supercode-cli/server/src/lib/model-access.ts
  • apps/supercode-cli/server/src/lib/plan-gate.ts
  • apps/supercode-cli/server/src/lib/request-counter.ts
  • apps/supercode-cli/server/src/lib/subscription-check.ts
  • apps/web/.env.example
  • apps/web/app/(pages)/pricing/page.tsx
  • apps/web/package.json
💤 Files with no reviewable changes (1)
  • apps/web/package.json

Comment on lines +77 to +94
const PRODUCT_CHECKOUT_URLS: Record<string, string> = {
// ── test mode ──
pdt_0Nk0u6EggnAdDxGtoLa1W: `${CHECKOUT_BASE}/pdt_0Nk0u6EggnAdDxGtoLa1W?quantity=1`,
pdt_0NkRjph71bwF2z3aBulCG: `${CHECKOUT_BASE}/pdt_0Nk0vwfI1kYrDaRsQzEUQ?quantity=1`,
pdt_0Nk0vS5WMtkT7u2T7P70e: `${CHECKOUT_BASE}/pdt_0Nk0vS5WMtkT7u2T7P70e?quantity=1`,
pdt_0NkRm62YJBP043k9sEDab: `${CHECKOUT_BASE}/pdt_0NkRm62YJBP043k9sEDab?quantity=1`,
pdt_0Nk0vwfI1kYrDaRsQzEUQ: `${CHECKOUT_BASE}/pdt_0Nk0vwfI1kYrDaRsQzEUQ?quantity=1`,
pdt_0NkRmfmsthQToUr41UAnd: `${CHECKOUT_BASE}/pdt_0NkRmfmsthQToUr41UAnd?quantity=1`,
pdt_0NkRmxBRNTOME3FYF9Qkg: `${CHECKOUT_BASE}/pdt_0NkRmxBRNTOME3FYF9Qkg?quantity=1`,
// ── live mode ──
pdt_0NkW4k2cUeO1f7a8yLje5: `${CHECKOUT_BASE}/pdt_0NkW4k2cUeO1f7a8yLje5?quantity=1`,
pdt_0NkW59I3J7uy2m1j0RAOF: `${CHECKOUT_BASE}/pdt_0NkW59I3J7uy2m1j0RAOF?quantity=1`,
pdt_0NkW5Vvq7Uxw55sjMlDFe: `${CHECKOUT_BASE}/pdt_0NkW5Vvq7Uxw55sjMlDFe?quantity=1`,
pdt_0NkW5s9M64jbHMoKrIpsl: `${CHECKOUT_BASE}/pdt_0NkW5s9M64jbHMoKrIpsl?quantity=1`,
pdt_0NkW6PfRqqooJXIMaX4Ci: `${CHECKOUT_BASE}/pdt_0NkW6PfRqqooJXIMaX4Ci?quantity=1`,
pdt_0NkW6cjti170KbFpeolfw: `${CHECKOUT_BASE}/pdt_0NkW6cjti170KbFpeolfw?quantity=1`,
pdt_0NkW6tnjud9Sp1iGr9TXj: `${CHECKOUT_BASE}/pdt_0NkW6tnjud9Sp1iGr9TXj?quantity=1`,
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

Line 80 maps a product ID to the checkout URL of a different product.

Every other entry uses the same identifier for the key and for the URL path. Line 80 does not:

pdt_0NkRjph71bwF2z3aBulCG: `${CHECKOUT_BASE}/pdt_0Nk0vwfI1kYrDaRsQzEUQ?quantity=1`,

The URL points to pdt_0Nk0vwfI1kYrDaRsQzEUQ, which is also the key on Line 83. A user who selects the plan whose dodoProductId is pdt_0NkRjph71bwF2z3aBulCG is charged for a different product, and the webhook then activates the wrong plan.

Two further concerns with this table:

  1. The map hardcodes test-mode and live-mode product IDs in the same object and ships both to the browser. A test-mode ID that reaches production produces a checkout that never settles.
  2. The map duplicates plan.dodoProductId, which the API already returns. The mapping is derivable, so it can drift from the database. If you adopt the server checkout endpoint suggested on Lines 313-322, delete this table.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/supercode-cli/client/app/studio/page.tsx` around lines 77 - 94, Remove
the hardcoded PRODUCT_CHECKOUT_URLS table and update the checkout flow to use
the server checkout endpoint, passing the plan.dodoProductId returned by the
API. Ensure the server selects the appropriate test or live product
configuration so browser code does not bundle both modes, and preserve
activation for the plan the user selected.

Comment on lines +313 to +322
const handleConfirmPayNow = useCallback(async () => {
if (!confirmingPlan || !userId) return
const url = getCheckoutUrl(confirmingPlan)
if (url) {
window.location.href = url
} else {
setConfirmingPlan(null)
toast.error("Checkout URL not available for this plan")
}
}, [confirmingPlan, userId])

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🔴 Critical | ⚡ Quick win

The direct checkout redirect drops userId, so the webhook cannot link the subscription.

handleConfirmPayNow sends the user to a static Dodo URL from PRODUCT_CHECKOUT_URLS. That URL carries no metadata.

handleSubscriptionActive in apps/supercode-cli/server/src/api/billing/webhook.ts reads data.metadata.userId (Line 95). Its only fallback is an existing local subscription row with the same dodoSubscriptionId (Lines 109-118). A first-time checkout has no such row. The handler then logs "Missing userId for subscription.active" and returns at Line 125. The customer pays, and no subscription is created.

The server endpoint apps/supercode-cli/server/src/api/billing/checkout.ts already attaches userId, planId, tier, and variant to the session metadata (Lines 72-77). Call that endpoint and redirect to the returned checkout_url.

🐛 Proposed fix
   const handleConfirmPayNow = useCallback(async () => {
     if (!confirmingPlan || !userId) return
-    const url = getCheckoutUrl(confirmingPlan)
-    if (url) {
-      window.location.href = url
-    } else {
-      setConfirmingPlan(null)
-      toast.error("Checkout URL not available for this plan")
-    }
+    try {
+      const res = await fetch("/api/billing/checkout", {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({ userId, planId: confirmingPlan.id }),
+      })
+      const data = await res.json()
+      if (!res.ok || !data.checkout_url) {
+        throw new Error(data.error ?? "Failed to start checkout")
+      }
+      window.location.href = data.checkout_url
+    } catch (err) {
+      setConfirmingPlan(null)
+      toast.error(err instanceof Error ? err.message : "Checkout failed")
+    }
   }, [confirmingPlan, userId])

This change also removes the need for PRODUCT_CHECKOUT_URLS and getCheckoutUrl.

📝 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
const handleConfirmPayNow = useCallback(async () => {
if (!confirmingPlan || !userId) return
const url = getCheckoutUrl(confirmingPlan)
if (url) {
window.location.href = url
} else {
setConfirmingPlan(null)
toast.error("Checkout URL not available for this plan")
}
}, [confirmingPlan, userId])
const handleConfirmPayNow = useCallback(async () => {
if (!confirmingPlan || !userId) return
try {
const res = await fetch("/api/billing/checkout", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId, planId: confirmingPlan.id }),
})
const data = await res.json()
if (!res.ok || !data.checkout_url) {
throw new Error(data.error ?? "Failed to start checkout")
}
window.location.href = data.checkout_url
} catch (err) {
setConfirmingPlan(null)
toast.error(err instanceof Error ? err.message : "Checkout failed")
}
}, [confirmingPlan, userId])
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/supercode-cli/client/app/studio/page.tsx` around lines 313 - 322, Update
handleConfirmPayNow to call the existing server checkout endpoint with the
selected plan and userId, then redirect to the returned checkout_url so session
metadata includes the user identity. Remove the direct
PRODUCT_CHECKOUT_URLS/getCheckoutUrl usage and preserve the existing error
toast/reset behavior when checkout creation or the URL is unavailable.

Comment on lines +131 to +149
router.post("/", async (req, res) => {
try {
const userId = req.body.userId as string | undefined
if (!userId) {
res.status(400).json({ error: "userId is required" })
return
}

const { action } = req.body
if (action !== "portal") {
res.status(400).json({ error: "Unknown action" })
return
}

const user = await prisma.user.findUnique({ where: { id: userId } })
if (!user?.dodoCustomerId) {
res.status(404).json({ error: "No Dodo customer linked to this account" })
return
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🔴 Critical | ⚡ Quick win

All three billing endpoints treat a client-supplied userId as proof of identity. No handler resolves a session. Any client can read another user's billing status, open that user's Dodo customer portal, cancel that user's subscription, refund it, or bind a new checkout to that account. The shared root cause is the missing server-side session resolution; apps/supercode-cli/server/src/index.ts already defines getUserFromBearer (Lines 162-176) for exactly this purpose.

  • apps/supercode-cli/server/src/api/billing/status.ts#L131-L149: resolve the user from the session in the POST handler, and apply the same check to the GET handler (Line 57) and the DELETE handler (Line 172). Ignore any userId in the request.
  • apps/supercode-cli/server/src/api/billing/checkout.ts#L20-L27: derive userId from the authenticated session instead of req.body.userId, and return 401 when no session exists.
  • apps/supercode-cli/server/src/api/billing/refund.ts#L13-L19: derive userId from the authenticated session instead of req.body.userId before you issue a refund or cancel a subscription.

Extract one shared middleware so every billing route applies the same check.

📍 Affects 3 files
  • apps/supercode-cli/server/src/api/billing/status.ts#L131-L149 (this comment)
  • apps/supercode-cli/server/src/api/billing/checkout.ts#L20-L27
  • apps/supercode-cli/server/src/api/billing/refund.ts#L13-L19
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/supercode-cli/server/src/api/billing/status.ts` around lines 131 - 149,
apps/supercode-cli/server/src/api/billing/status.ts:131-149,
apps/supercode-cli/server/src/api/billing/checkout.ts:20-27, and
apps/supercode-cli/server/src/api/billing/refund.ts:13-19 must stop trusting
client-supplied userId values. Extract shared authentication middleware using
getUserFromBearer from apps/supercode-cli/server/src/index.ts, apply it to all
billing routes including the status GET at line 57 and DELETE at line 172,
return 401 without a session, and derive the userId from the authenticated user
for portal, checkout, refund, and cancellation operations.

Comment on lines +139 to +141
app.use("/api/billing/webhook", webhookRouter)
// Alias so the Dodo dashboard's configured webhook URL works as documented in the plan
app.use("/api/webhooks/dodo-payments", webhookRouter)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🔴 Critical | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Locate body parser registration relative to the webhook route mounts.
fd -t f 'index.ts' apps/supercode-cli/server/src --max-depth 1 --exec rg -n -C2 'express\.json|express\.raw|express\.urlencoded|bodyParser|app\.use\("/api'

Repository: yashdev9274/supercli

Length of output: 856


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== index.js relevant sections =="
sed -n '80,145p' apps/supercode-cli/server/src/index.ts

echo
echo "== webhook handler/body parsing section =="
wc -l apps/supercode-cli/server/src/api/billing/webhook.ts
sed -n '330,380p' apps/supercode-cli/server/src/api/billing/webhook.ts

echo
echo "== imports and body parser registrations =="
rg -n "from ['\"]express['\"]|express\.json|express\.raw|app\.use\(\[|app\.use\(\"/api/billing/webhook\"|webhooks/dodo-payments" apps/supercode-cli/server/src/index.ts apps/supercode-cli/server/src/api/billing/webhook.ts

Repository: yashdev9274/supercli

Length of output: 4351


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== dodo package versions/lock entries =="
rg -n '"dodo-payment-sdk"|dodo-payment-sdk' package.json packages apps/supercode-cli server -g 'package.json' -g 'pnpm-lock.yaml' -g 'package-lock.json' -g 'yarn.lock' || true

echo
echo "== dodo unpkg metadata/type declarations for verify/unwrap =="
node - <<'JS'
const https = require('https')
const versions = [
  'latest',
  '1.0.0',
  '1.1.0',
  '1.2.0',
  '1.2.1',
  '1.3.0',
  '1.3.1',
  '1.4.0',
  '1.4.1',
  '1.5.0',
  '1.5.1',
]
async function p(url) {
  return new Promise((resolve, reject) => {
    https.get(url, res => {
      let chunks = []
      res.on('data', chunks.push.bind(chunks))
      res.on('end', resolve(Buffer.concat(chunks).toString()))
      res.on('error', reject)
    }).on('error', reject)
  })
}
async function main() {
  for (const v of versions) {
    try {
      const meta = JSON.parse(await p(`https://registry.npmjs.org/dodo-payment-sdk/${encodeURIComponent(v)}`))
      console.log(`\n-- ${v} --`)
      console.log(JSON.stringify({
        version: meta.version,
        types: meta.types || meta.typing,
        main: meta.main,
        typings: meta.typings,
      }, null, 2))
      for (const f of [meta.types || meta.typing, meta.main].filter(Boolean)) {
        try {
          const text = await p(`https://unpkg.com/dodo-payment-sdk@${v}/${f}`)
          if (text.includes('webhooks') || text.includes('unwrap') || text.includes('verify')) {
            const lines = text.split(/\r?\n/)
            for (let i = 0; i < lines.length; i++) {
              const line = lines[i]
              if (/webhooks|unwrap|verify|verifySignature|signPayload|createSignature/.test(line)) {
                const start = Math.max(0, i - 4)
                const end = Math.min(lines.length, i + 12)
                console.log(`\n@@ ${f}:${start+1}-${end+1} @@`)
                lines.slice(start, end).forEach((l, idx) => console.log(`${start+1+idx}\t${l}`))
              }
            }
          }
        } catch (e) {
          console.log(`could not fetch ${f}: ${e.message}`)
        }
      }
    } catch (e) {
      console.log(`${v} failed: ${e.message}`)
    }
  }
}
main()
JS

Repository: yashdev9274/supercli

Length of output: 1235


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== files named package.json in apps/packages =="
find . -maxdepth 4 -path './apps/*' -o -path './packages/*' | grep -E '/package\.json$' | head -200

echo
echo "== dodo references =="
grep -RIn --include='package.json' --include='*.ts' --include='*.tsx' --include='*.js' --include='pnpm-lock.yaml' --include='package-lock.json' --include='yarn.lock' 'dodo-payment-sdk|Dodo|dodo' . | head -200 || true

echo
echo "== app.use json/raw/urlencoded across server/src =="
grep -RIn --include='*.ts' --include='*.tsx' --include='*.js' 'express\.json|express\.raw|express\.urlencoded|bodyParser|app\.use\(\[' . | head -200 || true

echo
echo "== Dodo webhook implementation references in installed node_modules if available =="
find node_modules -path '*dodo*' -maxdepth 6 2>/dev/null | head -50

Repository: yashdev9274/supercli

Length of output: 1036


🌐 Web query:

dodo-payment-sdk webhooks unwrap rawBody signature verification TypeScript

💡 Result:

To securely handle and verify Dodo Payments webhooks in TypeScript, the recommended approach is to use the official Dodo Payments SDK or framework-specific adapters, which perform signature verification automatically [1][2][3]. ### Core Signature Verification When using the Dodo Payments SDK, you use the client.webhooks.unwrap method. This method takes the raw request body and the required webhook headers to verify the HMAC SHA256 signature [1]. Example TypeScript implementation: typescript import { DodoPayments } from 'dodopayments'; const client = new DodoPayments({ bearerToken: process.env.DODO_PAYMENTS_API_KEY!, webhookKey: process.env.DODO_PAYMENTS_WEBHOOK_KEY!, }); // Inside your request handler (e.g., Express or similar) try { const unwrapped = client.webhooks.unwrap(rawBody.toString(), { headers: { 'webhook-id': req.headers['webhook-id'] as string, 'webhook-signature': req.headers['webhook-signature'] as string, 'webhook-timestamp': req.headers['webhook-timestamp'] as string, }, }); // Process the verified event } catch (error) { // Handle invalid signature (e.g., return 401) res.status(401).json({ error: 'Invalid signature' }); } ### Key Requirements 1. Raw Body: You must provide the exact, unmodified raw body string to the unwrap method. Do not parse it as JSON before passing it to the SDK [4][1]. 2. Required Headers: You must extract and pass these three headers: - webhook-id - webhook-signature - webhook-timestamp [4][1][5] 3. Framework Adapters: For common frameworks like Next.js, use the @dodopayments/nextjs adapter. It handles signature verification, Zod validation, and routing automatically [2][6][3]. typescript // Example using `@dodopayments/nextjs` adapter export const POST = Webhooks({ webhookKey: process.env.DODO_WEBHOOKS_SECRET!, onPaymentCompleted: async (payload) => { // Logic for payment success }, }); Using the provided SDK and adapters ensures that your endpoint remains protected against unauthorized requests and tampered data [1][6][3].

Citations:


🌐 Web query:

Dodo Payments webhooks unwrap expects raw body string express raw bodyParser signature verification

💡 Result:

To correctly implement Dodo Payments webhook signature verification in an Express.js application, you must access the raw request body string. Because standard middleware like express.json parses the body into an object, it destroys the raw string format required for HMAC signature verification [1][2][3]. You should use express.raw to preserve the original body data [1][2][3]. Implementation Steps: 1. Configure Express to handle raw bodies: Use express.raw({ type: 'application/json' }) as middleware for your webhook endpoint [1][3]. This ensures req.body is available as a Buffer or string before any automatic JSON parsing occurs. 2. Verify the signature: When calling the Dodo Payments client's unwrap method, pass the raw body (converted to a string using.toString) and the required webhook headers [1][4][5]. Example implementation: import DodoPayments from 'dodopayments'; import express from 'express'; const app = express; // Use raw body parser for the webhook route app.post('/webhook', express.raw({ type: 'application/json' }), async (req, res) => { const client = new DodoPayments({ webhookKey: process.env.DODO_PAYMENTS_WEBHOOK_KEY, }); try { // Verify using the raw body string and headers const unwrapped = client.webhooks.unwrap(req.body.toString, { headers: { 'webhook-id': req.headers['webhook-id'] as string, 'webhook-signature': req.headers['webhook-signature'] as string, 'webhook-timestamp': req.headers['webhook-timestamp'] as string, }, }); // Handle the verified event res.json({ received: true }); } catch (error) { res.status(401).json({ error: 'Invalid signature' }); } }); Alternatively, if you prefer a pre-built solution, you can use the @dodopayments/express package, which handles the raw body parsing and signature verification automatically [6][7].

Citations:


🌐 Web query:

site:gitee.com Dodo Webhook verification rawBody signature bytes JSON.stringify behavior

💡 Result:

There is no standardized DoDo Open Platform webhook verification specification available in the provided search results. While several projects related to DoDo bots or various generic webhook verification implementations (such as Gitee's HmacSHA256 signature verification or generic JSON serialization practices) exist, none explicitly document a "rawBody" signature behavior for DoDo webhooks [1][2][3][4]. In typical webhook signature verification scenarios, the raw request body is required because using the parsed JSON object (e.g., via JSON.stringify) can alter the string representation—such as changing key order, adding or removing whitespace, or handling character encoding—which breaks the cryptographic signature match [1][5]. If you are implementing a verification mechanism, adhere to these common best practices: 1. Obtain the raw request body as bytes directly from the request stream before any body-parsing middleware (like body-parser in Express) processes or modifies it [1][6]. 2. Use this exact byte sequence to compute the signature using the platform's required algorithm (e.g., HMAC-SHA256) and your shared secret key [7][2][8]. 3. Ensure that your JSON serialization, if used for testing or re-computation, exactly matches the platform's transmission format. Avoid implicit serialization, as small discrepancies will cause signature mismatch [9][10]. For DoDo-specific integrations, refer to the official DoDo Open Platform developer documentation at open.imdodo.com, as third-party SDKs or implementations may not follow the latest platform security standards [3].

Citations:


🌐 Web query:

open.imdodo.com webhook signature verification req.body raw JSON.stringify

💡 Result:

When verifying webhooks for Dodo Payments (which follows the Standard Webhooks specification), it is critical that you use the raw, unmodified request body [1][2][3]. You should not use JSON.stringify(req.body) to reconstruct the payload for signature verification [4][5]. ### Why you should avoid JSON.stringify HMAC signature verification relies on comparing the hash of the exact byte sequence received from the provider against the signature provided in the headers [4]. If you use JSON.stringify(req.body) after the request has been parsed by middleware (like express.json()), the resulting string will likely differ from the original raw bytes due to: * Reordered object keys (JavaScript objects do not guarantee key order). * Different whitespace handling. * Character encoding differences [4]. Any minor change in the payload will result in a mismatch, causing signature verification to fail [4]. ### Recommended Implementation To correctly verify the signature, you must capture the raw body before it is parsed by any JSON middleware [4][5]. 1. Use Raw Middleware: In Express, configure a route to treat the incoming request as a raw buffer [1][6][5]. javascript // Use express.raw to get the body as a Buffer app.post('/webhook', express.raw({ type: 'application/json' }), async (req, res) => { // req.body is now a raw Buffer of the exact bytes received const rawBody = req.body.toString(); const headers = { 'webhook-id': req.headers['webhook-id'], 'webhook-signature': req.headers['webhook-signature'], 'webhook-timestamp': req.headers['webhook-timestamp'], }; // Use the official SDK or manual verification // Example using Dodo Payments SDK: const verified = client.webhooks.unwrap(rawBody, { headers }); ... }); 2. Manual Verification (Standard Webhooks): If not using the SDK, construct the signed message by concatenating the components with periods [1][2][3]: signedMessage = webhook-id + '.' + webhook-timestamp + '.' + rawBody Then, compute the HMAC SHA256 of this string using your webhook secret and compare it to the signature in the webhook-signature header [1][2][3]. If you are using frameworks that automatically parse JSON (like some serverless or Next.js configurations), ensure you read the request as a raw string (e.g., await req.text() in Next.js) before any parsing occurs [4].

Citations:


Parse the Dodo webhook body as raw bytes before signature verification.

express.json() is registered at apps/supercode-cli/server/src/index.ts:109 before the webhook routes at lines 139 and 141, so req.body is a parsed object. JSON.stringify(req.body) can change key order, whitespace, and Unicode escapes, which makes Dodo’s signature verification fail for legitimate events. Mount express.raw({ type: "application/json" }) for /api/billing/webhook and /api/webhooks/dodo-payments before the JSON parser, and pass req.body.toString("utf8") to webhooks.unwrap.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/supercode-cli/server/src/index.ts` around lines 139 - 141, Mount
express.raw({ type: "application/json" }) for both webhookRouter paths before
the global express.json() registration in the server setup. Update the webhook
signature verification flow to pass req.body.toString("utf8") directly to
webhooks.unwrap, avoiding JSON.stringify while preserving the existing webhook
handling for both aliases.

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