feat: update supercode-cli to version 0.1.93 and integrate Dodo Payments billing system: - #249
Conversation
…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.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
Your trial has ended. Reactivate Greptile to resume code reviews.
WalkthroughAdded 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. ChangesBilling and plan enforcement
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
Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 2📝 Generate docstrings 💡
🛠️ Fix failing CI checks 💡
🧪 Generate unit tests (beta)
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
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. Comment |
There was a problem hiding this comment.
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 winSame duplicate plan-gate pattern, with a different control-flow style than the other two sites.
This site uses an early
continueon 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 winSame duplicate plan-gate pattern as the other two call sites.
Identical structure to lines 2063-2071 and 2249-2255, with the same redundant
getMessagesfetch. 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 winDuplicate subscription-resolution logic can show a stale "current plan".
This block re-implements subscription-tier lookup instead of reusing
getSubscriptionPlanfromsubscription-check.ts. The reused version applies an expiration-based downgrade to Spark when a paid plan'scurrentPeriodEndhas passed (subscription-check.ts, lines 39-66); this raw query does not. If a subscription'sstatusfield lags behindcurrentPeriodEnd(for example, until a webhook or cron job updates it),/upgradeshows the stale paid tier as "Current plan" whilecheckPlanGatealready 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 liftTruncate action reports a ceiling but cannot truncate anything.
This function receives only
totalTokens: number, not the actual message list. Whenaction === "truncate", it returnstruncatedTokens: contextLimitandallowed: 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 usestruncatedTokensto 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 readsctx.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 liftContext enforcement result is discarded — the limit is never actually applied.
enforceContextLimitis called with the default "truncate" action, which always returnsallowed: truefor over-limit requests (seecontext-enforcer.ts, lines 27-33). This block only checksctx.allowed, so it never readsctx.truncatedTokensorctx.message. The caller inchat.ts(lines 2063-2071, 2167-2175, 2249-2255) does not truncate the message list either — it proceeds straight tostreamAIResponsewith the full, untrimmed conversation regardless ofplan.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 spanningcontext-enforcer.ts, this file, andchat.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 winDuplicate plan-gate pattern with a redundant message fetch.
This block repeats the same
loadContextTokens→checkPlanGate→ branch pattern found at lines 2167-2175 and 2249-2255.loadContextTokens(lines 27-34) fetches the conversation transcript viagetMessages, andstreamAIResponse(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 winFail-open on DB error defeats the monthly request cap.
If
prisma.usageEvent.aggregatethrows,getMonthlyRequestCountreturns0(line 20).checkRequestLimitthen reportsused: 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'sisModelAllowedForTier, which fails closed (returnsfalse) on a similar DB error. MakecheckRequestLimitfail 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 winFallback branch mislabels and hardcodes plan data.
When a paid plan expires and the code falls back to a Spark subscription, line 61 sets
isGrandfathered: trueunconditionally. This fallback subscription is not necessarily grandfathered — it only means the paid plan expired and a separate Spark row exists. Downstream consumers ofPlanInfo.isGrandfathered(grandfathering is a named PR objective) get the wrong signal for these users.Line 59 also hardcodes
modelAccess: "open"instead of readingfallback.plan.modelAccess. If the Spark plan'smodelAccessever 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
nullat line 65.plan-gate.tsthen 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 winRace 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 coverscost, both pass the check, and both decrement — leavingbalanceCentsnegative. 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 winSubstring matching on model slugs risks incorrect tier gating.
modelSlug.includes(m.slug)at line 35 matches any model whoseslugis a substring of the requestedmodelSlug, not just a prefixed variant. Short slugs (for example "hy3" used incredit-meter.ts's cost table) can accidentally match unrelated model identifiers that happen to contain that substring. SinceArray.prototype.findchecks each candidate's combined===/includescondition in array order, a coincidental substring match can win over the intended exact match, depending onModeltable 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 aprovider/slugsuffix 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 winPro (India) monthly price disagrees with this file's own FAQ and with the seeded plan price.
This card shows
₹649for Pro Monthly (India), but the FAQ entry at line 165 in this same file says"₹749/month", andapps/supercode-cli/server/prisma/seed.tsseeds the same plan atpriceCents: 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 winFAQ 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 winReconcile 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: 65900seeds 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: 770000seeds 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 winPro (India) plan prices don't match the marketing pricing page.
priceCents: 65900(line 78) charges ₹659/month, andpriceCents: 770000(line 108) charges ₹7,700/year for Pro (India).apps/web/app/(pages)/pricing/page.tsxadvertises ₹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 winWrap subscription creation and credit-balance upsert in one transaction.
If
creditBalance.upsert(line 47) fails aftersubscription.create(line 36) succeeds, the user ends up with an active subscription but no credits. Theusers.findManyfilter 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.$transactionso 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 winValidate
redirectbefore navigating to avoid an open redirect.
router.replace(get("redirect"))accepts attacker-controlled values directly, so/sign-in?redirect=https://evil.examplecan log a trusted user out or redirect them off-site after sign-in. Reject protocol URLs and restrictredirectto 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 winMove Prisma seed config into
prisma.config.ts.This package uses Prisma v7, where
package.json#prisma.seedis no longer read;prisma.config.tsis the source of truth and currently has nomigrations.seed, sobun run -with prisma db seedwill not executeprisma/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 winDo not write an unmapped provider status to the database.
Line 227 falls back to
data.statuswhen the status is not instatusMap. The rest of the codebase compares against a fixed set of values, for examplestatus: { in: ["active", "trialing"] }inapps/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 winCancel 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 winDerive
isGrandfatheredon the server, not from the query string.Line 8 reads the flag from the client.
apps/supercode-cli/client/app/studio/page.tsxLine 287 hardcodesisGrandfathered=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 isspark. Use that result for the filter. The client also sends avariantparameter 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 winSignal 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 unmappedproduct_idis 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 liftAdd 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:
handleSubscriptionRenewedcallsresetCredits, which setsbalanceCentsback to the full plan amount. A replayedsubscription.renewedevent refills credits that the user already consumed.handleSubscriptionActivere-runs the cancel-then-upsert sequence.Persist each
event.idin 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 winPass the real token count to
checkPlanGate, and avoid the hardcoded model default.Two points:
checkPlanGateacceptsopts.totalTokensand enforces the plan context limit only when that value is present (apps/supercode-cli/server/src/lib/plan-gate.tsLines 63-69). This call omits it, so context enforcement never runs on this path. Pass the computed token count for the request.- 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 winFix the fallback for a null
currentPeriodStart.Line 46 falls back to
new Date()whencurrentPeriodStartis null. The count on Line 47 then covers a zero-length window and always returns0.queriesUnderLimitis then alwaystrue, so the query threshold is not enforced for any subscription that lacks a period start.Use the subscription
createdAtas 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 winReturn 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.tsxLine 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 winThe 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 winReject the request when the user record does not exist.
Line 58 loads the user, but the code continues when
userisnull. 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 winThe grandfathered fallback payload omits fields the client requires.
The
SubscriptionInfotype inapps/supercode-cli/client/app/studio/page.tsx(Lines 58-66) declarescurrentPeriodStart,currentPeriodEnd,trialEndsAt,cancelAtPeriodEnd, andisGrandfathered. This branch sends onlyid,status, andmetadata.The direct consequence:
isGrandfatheredisundefined, so the client evaluatessubscription?.isGrandfathered ?? false(Line 311) asfalse. 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 winRecord 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 winReject the webhook when
DODO_PAYMENTS_WEBHOOK_KEYis not set.
getDodostill creates a client when onlyDODO_PAYMENTS_API_KEYis 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 winDocument
NEXT_PUBLIC_DODO_PUBLISHABLE_KEYfor this app.
DodoPaymentsScriptreadsprocess.env.NEXT_PUBLIC_DODO_PUBLISHABLE_KEY, but the client environment configuration documentation does not include it. AddNEXT_PUBLIC_DODO_PUBLISHABLE_KEYto the app’s client.env.exampleor 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 winGuard against a zero
limit.Line 178 divides by
limit. IfcurrentPlan.requestLimitis0, for example on an unlimited plan,pctbecomesNaN. The label then reads "(NaN%)", and the bar width isNaN%.🐛 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
⛔ Files ignored due to path filters (1)
bun.lockis excluded by!**/*.lock
📒 Files selected for processing (29)
apps/supercode-cli/client/app/layout.tsxapps/supercode-cli/client/app/studio/page.tsxapps/supercode-cli/client/components/DodoPaymentsScript.tsxapps/supercode-cli/client/components/auth/login-form.tsxapps/supercode-cli/client/next.config.tsapps/supercode-cli/client/vercel.jsonapps/supercode-cli/server/.env.exampleapps/supercode-cli/server/package.jsonapps/supercode-cli/server/prisma/add-grandfather-users.tsapps/supercode-cli/server/prisma/seed.tsapps/supercode-cli/server/src/api/billing/checkout.tsapps/supercode-cli/server/src/api/billing/plans.tsapps/supercode-cli/server/src/api/billing/refund.tsapps/supercode-cli/server/src/api/billing/status.tsapps/supercode-cli/server/src/api/billing/webhook.tsapps/supercode-cli/server/src/cli/ai/chat/chat.tsapps/supercode-cli/server/src/cli/commands/slashCommands/index.tsapps/supercode-cli/server/src/cli/commands/slashCommands/upgrade.tsapps/supercode-cli/server/src/index.tsapps/supercode-cli/server/src/lib/auth.tsapps/supercode-cli/server/src/lib/context-enforcer.tsapps/supercode-cli/server/src/lib/credit-meter.tsapps/supercode-cli/server/src/lib/model-access.tsapps/supercode-cli/server/src/lib/plan-gate.tsapps/supercode-cli/server/src/lib/request-counter.tsapps/supercode-cli/server/src/lib/subscription-check.tsapps/web/.env.exampleapps/web/app/(pages)/pricing/page.tsxapps/web/package.json
💤 Files with no reviewable changes (1)
- apps/web/package.json
| 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`, | ||
| } |
There was a problem hiding this comment.
🎯 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:
- 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.
- 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.
| 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]) |
There was a problem hiding this comment.
🗄️ 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.
| 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.
| 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 | ||
| } |
There was a problem hiding this comment.
🔒 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 anyuserIdin the request.apps/supercode-cli/server/src/api/billing/checkout.ts#L20-L27: deriveuserIdfrom the authenticated session instead ofreq.body.userId, and return 401 when no session exists.apps/supercode-cli/server/src/api/billing/refund.ts#L13-L19: deriveuserIdfrom the authenticated session instead ofreq.body.userIdbefore 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-L27apps/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.
| 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) |
There was a problem hiding this comment.
🗄️ 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.tsRepository: 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()
JSRepository: 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 -50Repository: 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:
- 1: https://dodopayments.mintlify.app/developer-resources/webhooks
- 2: https://github.com/dodopayments/dodo-adapters/
- 3: https://docs.dodopayments.com/developer-resources/framework-adaptors
- 4: https://github.com/sancho1952007/dodopayments-webhooks/blob/main/README.md
- 5: https://docs.dodopayments.com/developer-resources/webhooks/examples/netlify-example
- 6: https://dodopayments.com/blogs/add-payments-nextjs-app
🌐 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:
- 1: https://docs.dodopayments.com/developer-resources/webhooks
- 2: https://docs.dodopayments.com/developer-resources/handle-payment-failures
- 3: https://dodopayments.mintlify.app/developer-resources/webhooks
- 4: https://www.npmjs.com/package/dodopayments-webhooks
- 5: https://github.com/sancho1952007/dodopayments-webhooks
- 6: https://docs.dodopayments.com/developer-resources/express-adaptor
- 7: https://www.npmjs.com/package/@dodopayments/express
🌐 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:
- 1: https://gitee.com/jiandaoyun/webhook-demo-node
- 2: https://help.gitee.com/webhook/how-to-verify-webhook-keys
- 3: https://gitee.com/AlanStar233/dodo-Python-SDK
- 4: https://gitee.com/pudding/wechatbot-webhook
- 5: https://gitee.com/jiandaoyun/webhook-demo-ruby
- 6: https://gitee.com/jiandaoyun/webhook-demo-dotnet
- 7: https://help.gitee.com/enterprise/management/functions/webhooks/verify
- 8: https://gitee.com/chouway2021/skyland-auto-sign?skip_mobile=true
- 9: https://gitee.com/sinagamesdk/sina_eversdk/blob/master/%E6%96%B0%E6%B5%AA%E6%B8%B8%E6%88%8F%E8%9E%8D%E5%90%88SDK%E6%8E%A5%E5%85%A5%E6%96%87%E6%A1%A3%E2%80%94%E6%9C%8D%E5%8A%A1%E7%AB%AF.md
- 10: https://gitee.com/lcfkey/Daimali.ISV
🌐 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:
- 1: https://docs.dodopayments.com/developer-resources/webhooks
- 2: https://www.scien.cx/2026/03/19/handling-dodopayments-webhooks-with-firebase-cloud-functions/
- 3: https://dodopayments.mintlify.app/developer-resources/webhooks
- 4: https://webhookwhisper.com/webhook-errors/raw-body-required
- 5: https://hookmetry.com/docs/signature-validation
- 6: https://playbooks.com/skills/dodopayments/skills/webhook-integration
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.
Description
Type of change
How Has This Been Tested?
Please describe the tests that you ran to verify your changes.
bun testpassesbun run typecheckpassesbun run lintpasses (if applicable)Checklist:
Summary by CodeRabbit
New Features
/upgradeCLI command for comparing plans and opening billing settings.Improvements