-
Notifications
You must be signed in to change notification settings - Fork 26
Implement extra usage feature with Stripe integration #182
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
- Added a new webhook endpoint for handling Stripe events related to extra usage purchases. - Updated middleware to include the new webhook path for authentication. - Introduced components for managing extra usage settings, including dialogs for buying extra usage, adjusting spending limits, and configuring auto-reload options. - Enhanced user customization to support extra usage settings. - Updated rate limiting logic to incorporate extra usage balance checks, allowing users to utilize extra credits when limits are exceeded. - Added backend logic for managing extra usage credits and settings in the Convex database. This update enhances the user experience by providing flexible options for managing extra usage and integrating with Stripe for payments.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
📝 WalkthroughWalkthroughAdds a prepaid “Extra Usage” system: Stripe checkout + webhook, Convex points-based accounting and actions (add/deduct/refund/auto-reload), new frontend dialogs/UI and Account/Usage wiring, schema and user customization flag, and rate-limit integration consuming prepaid balance before enforcing buckets. Changes
Sequence Diagram(s)sequenceDiagram
participant User as Client (Browser)
participant App as Next.js App
participant Convex as Convex Backend
participant Stripe as Stripe
User->>App: Click "Buy Extra Usage"
App->>Convex: Convex action createPurchaseSession(amount)
Convex-->>App: { checkoutSessionUrl }
App->>User: Redirect to Stripe Checkout
User->>Stripe: Complete payment
Stripe->>App: POST /api/extra-usage/webhook (checkout.session.completed)
App->>App: Validate signature (STRIPE_EXTRA_USAGE_WEBHOOK_SECRET)
App->>Convex: addCredits(userId, amountDollars, idempotencyKey=event.id)
Convex-->>App: { newBalance, alreadyProcessed? }
App-->>Stripe: 200 OK
sequenceDiagram
participant Client as Chat Client
participant RateLimiter as Token Bucket
participant ExtraLib as Extra Usage Lib
participant Convex as Convex Backend
participant Stripe as Stripe
Client->>RateLimiter: Request tokens for chat
RateLimiter->>RateLimiter: Check bucket capacity
alt Bucket would overflow
RateLimiter->>ExtraLib: getExtraUsageBalance(userId)
ExtraLib->>Convex: read balance/settings
Convex-->>ExtraLib: { balancePoints, autoReloadEnabled }
alt Sufficient balance (or auto-reload succeeds)
RateLimiter->>ExtraLib: deductFromBalance(pointsNeeded)
ExtraLib->>Convex: deductPoints (may trigger auto-reload & Stripe charge)
Convex-->>ExtraLib: { success, newBalance }
ExtraLib-->>RateLimiter: Deduction success
RateLimiter-->>Client: Allow request
else Insufficient balance
RateLimiter-->>Client: Deny request (rate limit)
end
else Within bucket limits
RateLimiter-->>Client: Allow request
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~75 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
Comment |
Added saved_payment_method_options to createPurchaseSession so users can select their existing card instead of re-entering payment details each time. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 11
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
convex/userCustomization.ts (1)
90-107: Preserveextra_usage_enabledwhen the caller omits it.Line 103-106 currently defaults to
falseon partial updates, which will silently disable extra usage whenever users save other fields. Preserve the existing value whenargs.extra_usage_enabledisundefined.🐛 Proposed fix
- extra_usage_enabled: - args.extra_usage_enabled !== undefined - ? args.extra_usage_enabled - : false, // Default to disabled + extra_usage_enabled: + args.extra_usage_enabled !== undefined + ? args.extra_usage_enabled + : existing?.extra_usage_enabled ?? false, // Preserve existing
🤖 Fix all issues with AI agents
In `@app/api/extra-usage/webhook/route.ts`:
- Around line 54-66: In the checkout.session.completed handler, guard against
unpaid/processing payments by checking session.payment_status === "paid" before
crediting the user: if not "paid", return early (do not proceed to compute
amountDollars or fulfill the purchase). Update the logic around the session
variable (and the fulfillment function you call—e.g., fulfillExtraUsagePurchase
or similar) to require this check, and also make the fulfillment idempotent
and/or add handling for the checkout.session.async_payment_succeeded event so
delayed payments are credited when they actually succeed.
- Around line 78-84: The webhook handler currently calls the Convex mutation
api.extraUsage.addCredits without deduplicating Stripe events; update the
mutation to accept and persist the Stripe event.id (e.g., parameter eventId) and
implement an idempotency check in api.extraUsage.addCredits that queries/creates
a processed_events (or events) record with a unique constraint on eventId—if the
eventId already exists, the mutation should return without altering balance,
otherwise insert the eventId and proceed to add credits (amountDollars) to the
userId; also ensure the route passes the Stripe event.id into the mutation call.
In `@app/components/extra-usage/AutoReloadDialog.tsx`:
- Around line 113-117: In the AutoReloadDialog component update the branding in
the agreement paragraph: replace the literal "Anthropic" string with the correct
product/company name "HackerAI" (or reference a centralized BRAND_NAME constant
if one exists) so the paragraph rendered by AutoReloadDialog.tsx displays the
proper brand; ensure any tests or snapshots referencing that text are updated
accordingly.
- Around line 133-146: The Cancel button in AutoReloadDialog calls handleTurnOff
when isEnabled is false; change it to just close the dialog by calling the
dialog close handler instead: add an onOpenChange (or close) prop to the
component (update ContentProps to include onOpenChange: (open: boolean) =>
void), accept that prop in AutoReloadDialog, and replace the Cancel button's
onClick={handleTurnOff} with onClick={() => onOpenChange(false)} (or the
provided close function) when rendering the "Turn on" UI path; keep
handleTurnOff for the turn-off flow and ensure parent passes onOpenChange into
the content component.
In `@app/components/ExtraUsageSection.tsx`:
- Around line 241-252: The UI currently hardcodes the reset date in
ExtraUsageSection by computing the first of next month; instead, read the
backend-provided reset date from extraUsageSettings (e.g.,
extraUsageSettings.monthly_reset_date) when available and display that formatted
value, falling back to the existing new Date(...) computation only if
extraUsageSettings.monthly_reset_date is null/undefined; update the JSX near the
Resets {...} line to prefer the backend field and ensure you parse/format the
date with toLocaleDateString("en-US", { month: "short", day: "numeric" }) for
consistent display.
- Around line 63-65: The call to getPaymentStatus in ExtraUsageSection.tsx
passes no arguments, sending undefined to a Convex action that expects args: {};
update the invocation to call getPaymentStatus with an explicit empty object
(i.e., getPaymentStatus({})) so it matches the action signature and the pattern
used elsewhere (e.g., BuyExtraUsageDialog.tsx).
In `@convex/extraUsage.ts`:
- Around line 1-116: Both addCredits and deductPoints accept unvalidated numbers
(args.amountDollars / args.amountPoints) which allows negative or NaN values;
add runtime guards at the start of each handler to ensure the provided amount is
a finite positive number (e.g., use Number.isFinite(...) and > 0) before
proceeding (for addCredits check args.amountDollars, for deductPoints check
args.amountPoints), and if validation fails throw a clear error (e.g., throw new
Error("Invalid amount: must be a finite positive number")) so dollarsToPoints
and balance math cannot be abused.
In `@convex/extraUsageActions.ts`:
- Around line 283-299: The unit_amount passed to Stripe must be an integer cents
value but currently amountCents is computed as args.amountDollars * 100 which
can produce fractional cents; update the calculation of amountCents (used when
creating the session in checkout.sessions.create) to round to the nearest
integer (e.g., use Math.round on args.amountDollars * 100) so unit_amount is
always an integer before being passed to Stripe.
In `@convex/schema.ts`:
- Around line 117-129: The schema comment says "all monetary values stored in
POINTS" but the extra_usage table defines a field named
auto_reload_amount_dollars which is inconsistent and confusing; update the
schema to be explicit by either renaming auto_reload_amount_dollars to
auto_reload_amount_points (and keep it as v.number() representing POINTS) or, if
the value is truly in dollars, change the comment to list which fields are in
dollars and which are in points and rename the field to auto_reload_amount_cents
or document units clearly; make the change around the extra_usage table and
specifically reference auto_reload_amount_dollars in your edits and update the
surrounding comment to remove ambiguity.
In `@lib/extra-usage.ts`:
- Around line 84-90: The early-return when pointsUsed <= 0 returns a misleading
newBalanceDollars: 0; instead, return the actual current balance or omit the
field. Update the branch in the function containing pointsUsed so it returns
newBalanceDollars: currentBalanceDollars (or balanceDollars if that variable
exists in scope) or remove the newBalanceDollars property and make it optional
in the function's return type/interface; ensure callers handle the optional
field if you choose that route.
In `@lib/rate-limit/token-bucket.ts`:
- Around line 162-186: The current extra-usage path computes different
sessionDeduct and weeklyDeduct values (sessionDeduct/weeklyDeduct) which can
over-drain the larger bucket when extraUsage covers the shortfall; change the
logic inside the block that handles deductResult.success (the code that calls
weekly.limiter.limit and session.limiter.limit) to compute a single commonDeduct
= Math.min(estimatedCost, sessionCheck.remaining, weeklyCheck.remaining) and use
that same commonDeduct for both limiter.limit calls so neither bucket is
over-deducted; adjust any variable names accordingly near deductFromBalance,
pointsNeeded, estimatedCost, sessionCheck.remaining, weeklyCheck.remaining,
sessionDeduct, and weeklyDeduct.
🧹 Nitpick comments (7)
app/components/RateLimitWarning.tsx (1)
59-62: Simplify the redundant ternary.Since
bucketTypeis typed as"session" | "weekly", the ternarydata.bucketType === "session" ? "session" : "weekly"just returns the same value. You can usedata.bucketTypedirectly.Suggested simplification
- const limitType = data.bucketType === "session" ? "session" : "weekly"; + const limitType = data.bucketType;app/components/extra-usage/AdjustSpendingLimitDialog.tsx (2)
56-65: Input value includes the$prefix, which may cause cursor positioning issues.The input value is set as
$${inputValue}, embedding the dollar sign in the controlled value. This can cause awkward cursor behavior when users try to edit the beginning of the input. Consider using a separate visual prefix element (like in AutoReloadDialog lines 76-79) instead.♻️ Suggested approach using a positioned prefix
- <div> - <Input - type="text" - value={`$${inputValue}`} - onChange={(e) => { - const val = e.target.value.replace(/[^0-9.]/g, ""); - setInputValue(val); - }} - className="w-full" - aria-label="Monthly spending limit" - /> + <div className="relative"> + <span className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"> + $ + </span> + <Input + type="text" + value={inputValue} + onChange={(e) => { + const val = e.target.value.replace(/[^0-9.]/g, ""); + setInputValue(val); + }} + className="w-full pl-7" + aria-label="Monthly spending limit" + />
43-44: Validation allows multiple decimal points (e.g., "10.5.3").The regex on line 60 permits multiple
.characters, andparseFloatwill parse"10.5.3"as10.5, silently ignoring the rest. Consider either sanitizing to allow only one decimal point or adding explicit validation.♻️ Suggested fix to handle multiple decimals
const handleSetLimit = async () => { const limit = parseFloat(inputValue); - if (isNaN(limit) || limit < 0) return; + // Also reject if input has multiple dots + if (isNaN(limit) || limit < 0 || (inputValue.match(/\./g) || []).length > 1) return; await onSave(limit); };app/components/AccountTab.tsx (1)
31-39: Consider adding error logging for debugging failed admin status fetches.The catch block silently sets
isTeamAdmintofalseon any error. While this is a safe fallback, it may hide connectivity or API issues. Consider logging the error for debugging purposes.♻️ Suggested enhancement
if (subscription === "team") { fetch("/api/team/members") .then((res) => res.json()) .then((data) => setIsTeamAdmin(data.isAdmin ?? false)) - .catch(() => setIsTeamAdmin(false)); + .catch((error) => { + console.error("Failed to fetch team admin status:", error); + setIsTeamAdmin(false); + }); }app/components/extra-usage/BuyExtraUsageDialog.tsx (1)
105-116: Normalize amount to 2 decimals before purchase.This avoids fractional-cent inconsistencies when the backend converts dollars to cents.
💡 Suggested tweak
- const parsedAmount = parseFloat(removeCommas(purchaseAmount) || "0"); + const parsedAmount = parseFloat(removeCommas(purchaseAmount) || "0"); + const normalizedAmount = Math.round(parsedAmount * 100) / 100; - const isValidAmount = - !isNaN(parsedAmount) && parsedAmount >= 5 && parsedAmount <= MAX_AMOUNT; + const isValidAmount = + !isNaN(normalizedAmount) && + normalizedAmount >= 5 && + normalizedAmount <= MAX_AMOUNT; const handlePurchase = async () => { if (!isValidAmount) return; - await onPurchase(parsedAmount); + await onPurchase(normalizedAmount); };lib/extra-usage.ts (1)
1-4: Consider marking this module server-only to prevent client bundling.This file reads
CONVEX_SERVICE_ROLE_KEYand uses server-only Convex calls. Aserver-onlyguard helps prevent accidental client imports.🔒 Suggested guard
+import "server-only"; import { POINTS_PER_DOLLAR } from "@/lib/rate-limit/token-bucket"; import { ConvexHttpClient } from "convex/browser"; import { api } from "@/convex/_generated/api";convex/extraUsageActions.ts (1)
403-426: ConsiderinternalActionfor backend-onlydeductWithAutoReload.Line 403-426 is service-key gated and invoked from backend rate-limit logic; exposing it as a public action broadens the API surface unnecessarily. Prefer
internalActionandinternal.*references. As per coding guidelines, private functions should use internal actions.
| case "checkout.session.completed": { | ||
| const session = event.data.object as Stripe.Checkout.Session; | ||
| // Only process extra usage purchases | ||
| if (session.metadata?.type !== "extra_usage_purchase") { | ||
| return NextResponse.json({ received: true }); | ||
| } | ||
|
|
||
| const userId = session.metadata.userId; | ||
| // Support both new (amountDollars) and old (amountCents) metadata formats | ||
| const amountDollars = session.metadata.amountDollars | ||
| ? parseFloat(session.metadata.amountDollars) | ||
| : parseInt(session.metadata.amountCents, 10) / 100; | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🌐 Web query:
Stripe checkout.session.completed payment_status unpaid async payment methods when does event fire
💡 Result:
checkout.session.completed fires when the customer finishes the Stripe Checkout flow (i.e., reaches “complete”/success in the hosted UI), not necessarily when the funds are available.
- For instant payment methods (cards, etc.), the Session will usually arrive with
payment_status: "paid"and you can fulfill then. (docs.stripe.com) - For delayed / asynchronous payment methods (e.g., ACH debit/bank transfer types), Stripe can still send
checkout.session.completedwithpayment_status: "unpaid"because the customer completed Checkout but the payment outcome/funds aren’t finalized yet. You should wait to fulfill until you later receive:checkout.session.async_payment_succeeded(then fulfill), orcheckout.session.async_payment_failed(ask for another payment method). (docs.stripe.com)
Practical rule: Treat checkout.session.completed as “Checkout finished,” and gate fulfillment on session.payment_status == "paid" (or on async_payment_succeeded for delayed methods). (docs.stripe.com)
Citations:
- 1: https://docs.stripe.com/checkout/fulfillment?utm_source=openai
- 2: https://docs.stripe.com/checkout/fulfillment?utm_source=openai
- 3: https://docs.stripe.com/api/checkout/sessions?how=&utm_source=openai
🌐 Web query:
Stripe webhook async_payment_succeeded vs checkout.session.completed event timing
💡 Result:
checkout.session.completedis sent when the customer finishes the Checkout flow (they reach “success” in Checkout). For instant payment methods (cards, wallets), this typically happens at the same time the payment succeeds. (docs.stripe.com)- For delayed/asynchronous payment methods (for example bank debits/transfers), Checkout can complete while the payment is still
processing. In that case, Stripe later sendscheckout.session.async_payment_succeeded(orcheckout.session.async_payment_failed) when the payment outcome is known. (docs.stripe.com)
Practical timing expectation
- Instant methods: you usually get only
checkout.session.completed(no async event). (docs.stripe.com) - Delayed methods: you get
checkout.session.completedfirst, then (minutes → days later)checkout.session.async_payment_succeededor...failed. (docs.stripe.com)
Citations:
- 1: https://docs.stripe.com/checkout/fulfillment?utm_source=openai
- 2: https://docs.stripe.com/checkout/fulfillment?utm_source=openai
- 3: https://docs.stripe.com/payments/payment-element/migration-ewcs?utm_source=openai
- 4: https://docs.stripe.com/checkout/fulfillment?utm_source=openai
🌐 Web query:
Stripe checkout session fulfillment best practices payment not completed yet
💡 Result:
If a Checkout Session “completed” but the payment isn’t completed/final yet, the key is to fulfill based on server-side webhook events + status fields, not the redirect.
Best practices (Stripe-recommended)
-
Fulfill only from webhooks (not
success_url/ client callbacks).
Clients can close tabs or be spoofed; use asynchronous server events to trigger fulfillment. [4] -
In your fulfillment function, always re-fetch the Session and gate on
payment_status.
Stripe explicitly exposescheckout.session.payment_status(paid,unpaid,no_payment_required) and says you can use it to decide when to fulfill. [2]
Stripe’s fulfillment guide recommends your server-sidefulfill_checkout(session_id):- handles duplicate calls (idempotent),
- retrieves the Session (expand
line_items), - checks
payment_status, - fulfills,
- records fulfillment status. [1]
-
Handle delayed/asynchronous payment methods explicitly.
Some methods (ACH/bank transfers, etc.) can finish later: Checkout can complete while funds aren’t available yet. Stripe notes these generate:checkout.session.async_payment_succeeded(fulfill then)checkout.session.async_payment_failed(prompt for new payment) [1][3]
Practical rule:- On
checkout.session.completed: fulfill only ifpayment_status == "paid". - If
payment_status == "unpaid": wait forcheckout.session.async_payment_succeeded. [1][2][3]
-
Track session expiration / abandonment.
Sessions can expire; Stripe emitscheckout.session.expiredso you can stop waiting and optionally create a new Session/recovery flow. [3][5] -
Make fulfillment idempotent (must).
Webhooks can be delivered more than once or concurrently; record “fulfilled” bycheckout.session.id(and/or the underlying PaymentIntent/charge) and no-op on repeats. [1]
Minimal event set to listen for (most Checkout integrations)
checkout.session.completed→ fulfill only ifpayment_status=paid[1][2]checkout.session.async_payment_succeeded→ fulfill (delayed methods) [1][3]checkout.session.async_payment_failed→ mark failed / ask customer to retry [3]checkout.session.expired→ mark abandoned / trigger recovery flow [3][5]
Sources: Stripe “Fulfill orders” guide [1], Checkout Session API fields (payment_status) [2], Stripe event recommendations for async/expired sessions [3], Stripe webhook-first guidance for fulfillment [4], expired-session recovery/abandoned carts [5].
Guard against unpaid orders before crediting.
checkout.session.completed fires when the customer finishes Checkout, but the payment may not be finalized yet—especially for async payment methods (ACH, bank transfers). Stripe can send this event with payment_status: "unpaid" while the payment is still processing. Without a payment status check, the code will credit users for orders that may later fail or remain unpaid.
Add a payment_status guard:
✅ Suggested guard
case "checkout.session.completed": {
const session = event.data.object as Stripe.Checkout.Session;
+ if (session.payment_status !== "paid") {
+ console.warn(
+ "[Extra Usage Webhook] Session not paid yet:",
+ session.id,
+ );
+ return NextResponse.json({ received: true });
+ }
// Only process extra usage purchases
if (session.metadata?.type !== "extra_usage_purchase") {
return NextResponse.json({ received: true });
}Also consider:
- Listen for
checkout.session.async_payment_succeededto handle delayed payment completions - Make fulfillment idempotent to safely handle webhook retries
🤖 Prompt for AI Agents
In `@app/api/extra-usage/webhook/route.ts` around lines 54 - 66, In the
checkout.session.completed handler, guard against unpaid/processing payments by
checking session.payment_status === "paid" before crediting the user: if not
"paid", return early (do not proceed to compute amountDollars or fulfill the
purchase). Update the logic around the session variable (and the fulfillment
function you call—e.g., fulfillExtraUsagePurchase or similar) to require this
check, and also make the fulfillment idempotent and/or add handling for the
checkout.session.async_payment_succeeded event so delayed payments are credited
when they actually succeed.
| // Check if user has a valid payment method before enabling | ||
| const paymentStatus = await getPaymentStatus(); | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Find getPaymentStatus definition and calls
echo "=== getPaymentStatus calls in codebase ==="
rg "getPaymentStatus" --type=ts --type=tsx -B 2 -A 2
echo -e "\n=== Searching for action definition in convex ==="
rg "getPaymentStatus" convex --type=ts --type=tsx -B 5 -A 5Repository: hackerai-tech/hackerai
Length of output: 232
🏁 Script executed:
#!/bin/bash
# Find getPaymentStatus definition and calls
echo "=== getPaymentStatus calls in codebase ==="
rg "getPaymentStatus" -A 2 -B 2
echo -e "\n=== Check Convex action files ==="
fd "." convex --type f --extension ts --extension jsRepository: hackerai-tech/hackerai
Length of output: 3248
🏁 Script executed:
#!/bin/bash
# Get the full getPaymentStatus action definition
cat -n convex/extraUsageActions.ts | head -50Repository: hackerai-tech/hackerai
Length of output: 1945
🏁 Script executed:
#!/bin/bash
# Find the getPaymentStatus action definition
rg -n "export const getPaymentStatus" convex/extraUsageActions.ts -A 20Repository: hackerai-tech/hackerai
Length of output: 745
Pass explicit args to getPaymentStatus (Convex action signature).
The action is defined with args: {}. Calling without arguments passes undefined instead of an empty object, which is inconsistent with the validator and the pattern used elsewhere (e.g., BuyExtraUsageDialog.tsx calls it with {}). Use await getPaymentStatus({}) for consistency and correctness.
✅ Suggested fix
- const paymentStatus = await getPaymentStatus();
+ const paymentStatus = await getPaymentStatus({});📝 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.
| // Check if user has a valid payment method before enabling | |
| const paymentStatus = await getPaymentStatus(); | |
| // Check if user has a valid payment method before enabling | |
| const paymentStatus = await getPaymentStatus({}); |
🤖 Prompt for AI Agents
In `@app/components/ExtraUsageSection.tsx` around lines 63 - 65, The call to
getPaymentStatus in ExtraUsageSection.tsx passes no arguments, sending undefined
to a Convex action that expects args: {}; update the invocation to call
getPaymentStatus with an explicit empty object (i.e., getPaymentStatus({})) so
it matches the action signature and the pattern used elsewhere (e.g.,
BuyExtraUsageDialog.tsx).
| const stripe = getStripe(); | ||
| const amountCents = args.amountDollars * 100; | ||
|
|
||
| const session = await stripe.checkout.sessions.create({ | ||
| customer: stripeCustomerId, | ||
| mode: "payment", | ||
| payment_method_types: ["card"], | ||
| line_items: [ | ||
| { | ||
| price_data: { | ||
| currency: "usd", | ||
| product_data: { | ||
| name: "HackerAI Extra Usage Credits", | ||
| description: `$${args.amountDollars} in extra usage credits`, | ||
| }, | ||
| unit_amount: amountCents, | ||
| }, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
# First, let's locate and examine the file
find . -name "extraUsageActions.ts" -type fRepository: hackerai-tech/hackerai
Length of output: 95
🏁 Script executed:
# Read the relevant section of the file around lines 283-299
cat -n convex/extraUsageActions.ts | sed -n '270,310p'Repository: hackerai-tech/hackerai
Length of output: 1679
🏁 Script executed:
# Get the full function context to understand args and visibility
cat -n convex/extraUsageActions.ts | sed -n '260,320p'Repository: hackerai-tech/hackerai
Length of output: 2376
🏁 Script executed:
# Check the beginning of the file for function definition and args validator
head -n 50 convex/extraUsageActions.tsRepository: hackerai-tech/hackerai
Length of output: 1595
🏁 Script executed:
# Find the function that contains the code around line 283-299
cat -n convex/extraUsageActions.ts | sed -n '240,265p'Repository: hackerai-tech/hackerai
Length of output: 1095
🏁 Script executed:
# Get the function signature and args validator
cat -n convex/extraUsageActions.ts | sed -n '220,260p'Repository: hackerai-tech/hackerai
Length of output: 1603
🏁 Script executed:
# Check if amountDollars is validated as integer or number in args
rg "amountDollars" convex/extraUsageActions.ts -B 5 -A 2Repository: hackerai-tech/hackerai
Length of output: 2080
🌐 Web query:
Stripe checkout sessions unit_amount integer requirement cents
💡 Result:
In Stripe Checkout Sessions, line_items[].price_data.unit_amount must be an integer in the currency’s smallest unit—for USD that means cents (e.g., $20.00 → 2000) and it must be non‑negative. One of unit_amount or unit_amount_decimal is required (and you can’t set both). [1]
If you need fractional minor units, use unit_amount_decimal (a decimal value in cents with up to 12 decimal places). [1]
Most Stripe SDKs enforce this the same way (e.g., Java’s setUnitAmount(Long) is “integer in cents (or local equivalent)”). [3]
Round unit_amount to ensure it's an integer cent value for Stripe.
Line 284 calculates amountCents = args.amountDollars * 100. Since amountDollars is validated as v.number(), it can accept decimal values (e.g., 5.75). Floating-point arithmetic may produce fractional cents, but Stripe's unit_amount requires an integer. Use Math.round() to ensure an integer:
Proposed fix
- const amountCents = args.amountDollars * 100;
+ const amountCents = Math.round(args.amountDollars * 100);📝 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 stripe = getStripe(); | |
| const amountCents = args.amountDollars * 100; | |
| const session = await stripe.checkout.sessions.create({ | |
| customer: stripeCustomerId, | |
| mode: "payment", | |
| payment_method_types: ["card"], | |
| line_items: [ | |
| { | |
| price_data: { | |
| currency: "usd", | |
| product_data: { | |
| name: "HackerAI Extra Usage Credits", | |
| description: `$${args.amountDollars} in extra usage credits`, | |
| }, | |
| unit_amount: amountCents, | |
| }, | |
| const stripe = getStripe(); | |
| const amountCents = Math.round(args.amountDollars * 100); | |
| const session = await stripe.checkout.sessions.create({ | |
| customer: stripeCustomerId, | |
| mode: "payment", | |
| payment_method_types: ["card"], | |
| line_items: [ | |
| { | |
| price_data: { | |
| currency: "usd", | |
| product_data: { | |
| name: "HackerAI Extra Usage Credits", | |
| description: `$${args.amountDollars} in extra usage credits`, | |
| }, | |
| unit_amount: amountCents, | |
| }, |
🤖 Prompt for AI Agents
In `@convex/extraUsageActions.ts` around lines 283 - 299, The unit_amount passed
to Stripe must be an integer cents value but currently amountCents is computed
as args.amountDollars * 100 which can produce fractional cents; update the
calculation of amountCents (used when creating the session in
checkout.sessions.create) to round to the nearest integer (e.g., use Math.round
on args.amountDollars * 100) so unit_amount is always an integer before being
passed to Stripe.
- Add monthlyCapExceeded check in deductPoints mutation before allowing deduction - Propagate monthlyCapExceeded flag through deductWithAutoReload action and deductFromBalance - Show specific error message when monthly cap exceeded, recommending user increase limit in Settings - Previously, hitting the monthly cap would still allow extra usage to be deducted Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
…ents - Enhanced error handling in AccountTab to provide clearer feedback when failing to open the billing portal. - Introduced a new function in ExtraUsageSection to determine color classes based on usage percentage, improving visual feedback on usage levels. - Updated various dialogs in the extra usage section to ensure consistent formatting and clarity in user messages. These changes aim to enhance user experience by providing better error messages and visual cues related to usage and spending limits.
|
Caution Failed to replace (edit) comment. This is likely due to insufficient permissions or the comment being deleted. Error details |
- Add flex-wrap to Current Balance Row to prevent auto-reload text overlapping with Buy button on mobile - Allow setting unlimited monthly spending limit by accepting null in mutation Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 3
🤖 Fix all issues with AI agents
In `@convex/extraUsage.ts`:
- Around line 12-20: The points↔dollars conversion in pointsToDollars currently
does a raw division and must match the rounding/multiplier behavior used in
lib/extra-usage.ts; update pointsToDollars (and/or dollarsToPoints) to use the
same cents-multiplier and rounding logic as the shared helper in
lib/extra-usage.ts (or import and call that helper directly) so balances, caps
and UI displays remain consistent—reference POINTS_PER_DOLLAR, dollarsToPoints
and pointsToDollars when making the change.
- Around line 132-143: The check that rejects a deduction when monthlyCapPoints
is exceeded currently returns insufficientFunds: true which is misleading;
update the return in the monthly cap branch (the block using monthlyCapPoints,
monthlySpentPoints, args.amountPoints) to set insufficientFunds: false while
keeping monthlyCapExceeded: true and returning
newBalancePoints/currentBalancePoints and newBalanceDollars via
pointsToDollars(currentBalancePoints) so callers know the failure is due to cap
not lack of funds.
- Around line 206-216: The code uses truthy checks that drop valid zero values
for thresholds/caps; update the checks in the return object so that you test
explicitly for null/undefined (e.g., use settings?.auto_reload_threshold_points
!= null or typeof settings?.auto_reload_threshold_points !== 'undefined')
instead of simple truthy checks when computing autoReloadThresholdDollars and
any similar fields (also fix the analogous logic referenced around lines
258-264); keep using pointsToDollars when the value exists (including 0) and
only return undefined when the original is null or undefined, and ensure flags
like customization?.extra_usage_enabled and settings?.auto_reload_enabled remain
boolean defaults as intended.
♻️ Duplicate comments (1)
convex/extraUsage.ts (1)
30-43: Validate positive finite amounts in addCredits/deductPoints (already flagged).Same issue as previous review:
amountDollars/amountPointsaccept negative or NaN values, enabling balance manipulation.Also applies to: 76-112
… components - Updated the redirectToBillingPortal function to return the URL instead of redirecting directly, allowing for better handling of the redirection in the UI. - Modified the AutoReloadDialog to reflect the correct branding by changing "Anthropic" to "HackerAI". - Removed redundant purchase confirmation text in the BuyExtraUsageDialog to streamline the user interface. These changes improve the user experience by ensuring accurate branding and enhancing the functionality of the billing portal redirection.
- Add refundPoints mutation to Convex for extra usage balance refunds - Add refundToBalance function to call Convex mutation - Add refundBucketTokens to restore Redis token bucket credits - Add refundUsage to orchestrate both bucket and extra usage refunds - Add UsageRefundTracker class for idempotent refund handling - Track pointsDeducted and extraUsagePointsDeducted in RateLimitInfo - Integrate refund into chat-handler's onError and catch block When a chat request fails after credits are deducted, both token bucket credits (session/weekly) and extra usage balance are now refunded. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 7
🤖 Fix all issues with AI agents
In `@app/components/extra-usage/BuyExtraUsageDialog.tsx`:
- Around line 151-158: parsedAmount can be NaN when the input contains only
non-numeric characters, causing parsedAmount.toFixed(2) to render "NaN"; in
BuyExtraUsageDialog fix this by deriving a safe display value (e.g., const
displayAmount = Number.isFinite(parsedAmount) ? parsedAmount : 0) and use
displayAmount with formatWithCommas(...displayAmount.toFixed(2)) in the "Extra
usage" and "Total due" spans so the UI shows "$0.00" instead of "$NaN".
- Around line 133-137: The onChange handler currently strips non-digits and dots
but allows multiple decimals (e.g., "1.2.3"); after generating the initial
cleaned string (the current val from e.target.value.replace(/[^0-9.]/g, "")),
remove any extra decimal points by keeping only the first '.' (e.g., locate the
first '.' and strip all subsequent '.' characters) before calling
setPurchaseAmount so the displayed input never contains more than one decimal
point; update the onChange in BuyExtraUsageDialog to perform this extra-dot
removal step.
- Around line 87-103: The payment method cache is cleared in
handleEditPaymentMethod but the component stays mounted so useEffect won't
re-run when the user returns from the Stripe portal; add a visibility/focus
listener to re-fetch the payment method (or call the existing fetch function
used in the component's useEffect) when document.visibilityState becomes
"visible" or window receives focus, and clean up the listeners on unmount;
reference handleEditPaymentMethod to keep the existing setPaymentMethod(null)
behavior and call the component's payment-fetch function (or setPaymentMethod
with a fresh value) from the visibility/focus handler so the UI updates without
closing the dialog.
In `@convex/extraUsage.ts`:
- Around line 362-380: Validate that args.autoReloadThresholdDollars,
args.autoReloadAmountDollars, and (when not null) args.monthlyCapDollars are
finite positive numbers before converting or assigning them: for each field
check typeof value === "number", Number.isFinite(value) and value > 0, and if
any check fails throw a clear error (e.g., RangeError or a ValidationError) so
invalid negatives/NaN/Infinity are rejected; keep the monthlyCapDollars null
handling (null means unlimited) and only call dollarsToPoints for values that
pass the checks, updating updateData as before.
In `@lib/api/chat-handler.ts`:
- Around line 186-203: When extra usage is enabled you must always populate
extraUsageConfig (even if balance is zero) instead of only when balanceDollars >
0; in the extra-usage block in chat-handler.ts where you call
getExtraUsageBalance(userId), set extraUsageConfig = { enabled: true,
hasBalance: (balanceInfo?.balanceDollars ?? 0) > 0, balanceDollars:
balanceInfo?.balanceDollars ?? 0 } so downstream checks in token-bucket.ts
(which inspect extraUsageConfig?.enabled and extraUsageConfig.hasBalance) see
the correct enabled state and can perform auto-reload logic.
In `@lib/extra-usage.ts`:
- Around line 93-102: The no-op branch in refundToBalance currently returns
success:true and newBalanceDollars:0 which can incorrectly overwrite an actual
balance; change the <=0 branch to either (A) retrieve and return the current
balance in the RefundBalanceResult (call your existing balance getter for the
user) or (B) omit/return undefined for newBalanceDollars so callers don't
overwrite state—update refundToBalance and the RefundBalanceResult usage
accordingly.
In `@lib/rate-limit/refund.ts`:
- Around line 42-61: The refund() method sets hasRefunded before calling
refundUsage, which prevents retries if refundUsage fails; change refund() to
defer setting hasRefunded until after a successful refund and add an in‑flight
guard (e.g., a refundPromise property) so concurrent calls await the same
promise: check hasRefunded and refundPromise at the start, if refundPromise
exists return/await it, create and assign refundPromise when starting
refundUsage, await refundUsage, on success set hasRefunded = true and clear
refundPromise, and on failure clear refundPromise (do not set hasRefunded) so
retries remain possible; reference refund(), hasRefunded, hasDeductions(),
refundUsage, and add refundPromise (or similar) as the unique symbols to modify.
♻️ Duplicate comments (10)
app/components/extra-usage/AutoReloadDialog.tsx (1)
133-146: Cancel button incorrectly callshandleTurnOffwhen enabling auto-reload.When
isEnabledisfalse, the user is trying to turn ON auto-reload. The Cancel button should simply close the dialog, not execute the turn-off logic. This requires passingonOpenChangeto the content component.🐛 Proposed fix
Update
ContentPropsto includeonOpenChange:-type ContentProps = Omit<AutoReloadDialogProps, "open" | "onOpenChange">; +type ContentProps = Omit<AutoReloadDialogProps, "open">;Update the content component signature and Cancel button:
const AutoReloadDialogContent = ({ onSave, onTurnOff, isLoading, isEnabled, currentThresholdDollars, currentAmountDollars, + onOpenChange, }: ContentProps) => {) : ( <> <Button variant="outline" - onClick={handleTurnOff} + onClick={() => onOpenChange(false)} disabled={isLoading} > Cancel </Button>And pass
onOpenChangein the parent:<AutoReloadDialogContent onSave={onSave} onTurnOff={onTurnOff} isLoading={isLoading} isEnabled={isEnabled} currentThresholdDollars={currentThresholdDollars} currentAmountDollars={currentAmountDollars} + onOpenChange={onOpenChange} />lib/extra-usage.ts (1)
136-147: Duplicate: no‑op deduction still returns $0 balance.Same issue as earlier feedback on this file—consider applying the previously suggested fix here as well.
lib/rate-limit/token-bucket.ts (1)
177-210: Over-deduction issue in extra usage path persists.The previous review flagged that
sessionDeductandweeklyDeductare computed independently (Lines 180-181), causing the bucket with more capacity to be over-drained when extra usage covers the shortfall. This issue remains unaddressed.convex/extraUsage.ts (7)
12-20: Points↔dollars conversion mismatch with lib/extra-usage.ts remains unaddressed.The previous review noted that
lib/extra-usage.tsapplies a multiplier and rounds to cents, while this file does raw division. This can cause inconsistent balance displays between backend and frontend.
39-42: Missing input validation for amountDollars.The previous review flagged that
args.amountDollarsshould be validated to ensure it's a positive finite number before processing. Negative or NaN values could corrupt the balance.
89-91: Missing input validation for amountPoints.The previous review flagged that
args.amountPointsshould be validated to ensure it's a positive finite number before processing.
136-144:insufficientFunds: trueis misleading when only monthly cap is exceeded.The previous review noted that when the monthly cap blocks a deduction but the user has sufficient balance,
insufficientFundsshould befalseto avoid confusing callers.
278-281: Truthy check drops valid zero threshold values.The previous review noted that using
thresholdPoints ? ...drops valid zero values. Use explicit null/undefined checks instead.
322-328: Same truthy check issue for threshold and cap values.The previous review's note about truthy checks also applies here—
settings.auto_reload_threshold_pointsandsettings.monthly_cap_pointsshould use explicit null/undefined checks.
185-194: Extend validation to check for finite numbers.The
<= 0check (Line 188) catches negative values but notNaNorInfinity. AddNumber.isFinite(args.amountPoints)to prevent corrupted balance data.Proposed fix
handler: async (ctx, args) => { validateServiceKey(args.serviceKey); - if (args.amountPoints <= 0) { + if (!Number.isFinite(args.amountPoints) || args.amountPoints <= 0) { return { success: true, newBalancePoints: 0, newBalanceDollars: 0, }; }
🧹 Nitpick comments (5)
app/components/extra-usage/AutoReloadDialog.tsx (1)
45-54: Consider adding validation for non-positive values.
parseFloataccepts negative numbers, so a user could potentially bypass the HTMLminattributes (e.g., by typing directly). While backend validation should catch this, adding client-side validation would improve UX.✨ Optional enhancement
const handleSubmit = async () => { const thresholdDollars = parseFloat(threshold); const amountDollars = parseFloat(amount); - if (isNaN(thresholdDollars) || isNaN(amountDollars)) { + if ( + isNaN(thresholdDollars) || + isNaN(amountDollars) || + thresholdDollars < 1 || + amountDollars < 5 + ) { return; } await onSave(thresholdDollars, amountDollars); };app/components/extra-usage/BuyExtraUsageDialog.tsx (2)
69-85: Consider showing an error state if payment method fetch fails.If
getPaymentStatusfails, the component falls back to showing "Link by Stripe" which could mislead users who have a saved payment method. Consider adding an error state or a retry mechanism.
45-49:onCloseprop is passed but never used.
BuyExtraUsageDialogContentacceptsonClosein its props (line 48, 54) but never calls it. Either remove the unused prop or use it where appropriate (e.g., after successful purchase).Option 1: Remove unused prop
type ContentProps = { onPurchase: (amountDollars: number) => Promise<void>; isLoading: boolean; - onClose: () => void; }; const BuyExtraUsageDialogContent = ({ onPurchase, isLoading, - onClose, }: ContentProps) => {<BuyExtraUsageDialogContent onPurchase={onPurchase} isLoading={isLoading} - onClose={() => onOpenChange(false)} />Also applies to: 204-227
lib/rate-limit/token-bucket.ts (1)
356-363: Consider logging failed extra usage deductions for observability.When
deductFromBalancefails in the post-request path (Line 362), the error is silently swallowed. While this is intentional per the comment at Line 365, consider adding a warning log for failed deductions to aid debugging and track potential revenue leakage.Suggested improvement
// Deduct remainder from extra usage if enabled if ( fromExtraUsage > 0 && extraUsageConfig?.enabled && extraUsageConfig.hasBalance ) { - await deductFromBalance(userId, fromExtraUsage); + const result = await deductFromBalance(userId, fromExtraUsage); + if (!result.success) { + console.warn(`Failed to deduct ${fromExtraUsage} points from extra usage for user ${userId}`); + } }convex/extraUsage.ts (1)
385-390: Minor:updated_atis set twice.
updateDataalready containsupdated_at: Date.now()(Line 359), but Line 389 sets it again when inserting. The spread order ensures the second value wins, so it's functionally correct but redundant.Cleaner approach
} else { await ctx.db.insert("extra_usage", { user_id: identity.subject, balance_points: 0, ...updateData, - updated_at: Date.now(), }); }
- Updated the BuyExtraUsageDialog to ensure only whole dollar values are accepted and displayed. - Simplified number formatting by removing decimal handling and ensuring consistent display of purchase amounts. - Enhanced createPurchaseSession action to validate that the amount is a whole dollar value. - Adjusted extra usage configuration to include auto-reload status, allowing usage even with a $0 balance. These changes enhance user experience by enforcing stricter input validation and improving the clarity of displayed amounts.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 1
🤖 Fix all issues with AI agents
In `@app/components/extra-usage/BuyExtraUsageDialog.tsx`:
- Around line 87-93: The call to window.open in BuyExtraUsageDialog (after
createBillingPortalSession returns result.url) is vulnerable to
reverse-tabnabbing; update the window.open invocation to include the
"noopener,noreferrer" feature string (or alternatively open via an anchor with
rel="noopener noreferrer") so the new tab cannot access window.opener and cannot
navigate the original page.
♻️ Duplicate comments (2)
app/components/extra-usage/BuyExtraUsageDialog.tsx (1)
85-95: Payment method won’t refresh when returning from the billing portal.
setPaymentMethod(null)clears the cache, but the component remains mounted anduseEffectwon’t re-run on return. Add a focus/visibility listener (or re-fetch on tab focus) so the UI updates without closing the dialog.lib/rate-limit/token-bucket.ts (1)
180-213: Over-deduction from weekly bucket when session is tighter.The past review concern about over-deducting from buckets is still present. When extra usage covers the shortfall, the code deducts different amounts from session vs weekly buckets (lines 183-188), which over-drains the bucket with more remaining capacity.
Example scenario:
estimatedCost = 100sessionCheck.remaining = 30,weeklyCheck.remaining = 80sessionDeduct = 30,weeklyDeduct = 80pointsNeeded = 70(from extra usage)The weekly bucket loses 80 points when it should only lose 30 (matching the session). The total deducted (30 + 80 + 70 = 180) exceeds the actual cost (100) in terms of bucket drain.
🐛 Proposed fix: Deduct the shared minimum from both buckets
if (deductResult.success) { // Balance deducted - deduct what we can from buckets and continue - // Deduct only the remaining capacity from each bucket (don't go negative) - const sessionDeduct = Math.min(estimatedCost, sessionCheck.remaining); - const weeklyDeduct = Math.min(estimatedCost, weeklyCheck.remaining); + // Deduct the same amount from both buckets (the minimum available) + const bucketDeduct = Math.min( + estimatedCost, + sessionCheck.remaining, + weeklyCheck.remaining, + ); const [weeklyResult, sessionResult] = await Promise.all([ - weekly.limiter.limit(weekly.key, { rate: weeklyDeduct }), - session.limiter.limit(session.key, { rate: sessionDeduct }), + weekly.limiter.limit(weekly.key, { rate: bucketDeduct }), + session.limiter.limit(session.key, { rate: bucketDeduct }), ]);
🧹 Nitpick comments (1)
lib/rate-limit/refund.ts (1)
42-63: Refund retry logic improved; minor race window remains.The previous issue with
hasRefundedbeing set before the refund call has been addressed - it's now set only after success (line 58), allowing retries on transient failures.However, if two concurrent calls to
refund()occur before the first completes, both could pass thehasRefundedcheck and executerefundUsagesimultaneously. This is unlikely in practice but could be guarded with an in-flight promise pattern.🔧 Optional: Add in-flight guard for concurrent safety
export class UsageRefundTracker { private pointsDeducted = 0; private extraUsagePointsDeducted = 0; private userId: string | undefined; private subscription: SubscriptionTier | undefined; private hasRefunded = false; + private refundInFlight: Promise<void> | undefined; // ... other methods ... async refund(): Promise<void> { if (this.hasRefunded || !this.hasDeductions()) { return; } if (!this.userId || !this.subscription) { return; } + // Return existing promise if refund is already in flight + if (this.refundInFlight) { + return this.refundInFlight; + } + + this.refundInFlight = this.executeRefund(); + return this.refundInFlight; + } + + private async executeRefund(): Promise<void> { try { await refundUsage( this.userId, this.subscription, this.pointsDeducted, this.extraUsagePointsDeducted, ); this.hasRefunded = true; } catch (error) { console.error("Failed to refund usage:", error); + this.refundInFlight = undefined; // Allow retry // Flag stays false, allowing retry on transient failures } } }
- Add webhook idempotency using Stripe event ID to prevent double-crediting - Fix AutoReloadDialog Cancel button calling onTurnOff (now uses onCancel) - Add positive amount validation in addCredits mutation - Clarify schema comment: dollars vs points for auto_reload_amount_dollars - Add noOp field to indicate no-op returns in deduct/refund functions - Add noopener,noreferrer to window.open for security - Fix bucket deduction to only deduct subscription contribution Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This update enhances the user experience by providing flexible options for managing extra usage and integrating with Stripe for payments.
Summary by CodeRabbit
New Features
Improvements
Chores
✏️ Tip: You can customize this high-level summary in your review settings.