Redesign billing: pay-as-you-go first-class + coupon redemption#337
Conversation
- Refactored the BillingPanel component to streamline its structure and improve readability by removing unused constants and functions. - Introduced new components: AutoRechargeSection, InferenceBudget, PayAsYouGoCard, and SubscriptionPlans to modularize billing functionalities. - Added coupon redemption functionality in the credits API, allowing users to redeem coupon codes for credits. - Enhanced the overall billing experience by improving the layout and organization of billing-related UI elements.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
📝 WalkthroughWalkthroughThe billing panel UI is refactored by extracting large inline code sections into dedicated child components (PayAsYouGoCard, SubscriptionPlans, InferenceBudget, AutoRechargeSection). New coupon redemption functionality is added via API endpoints and RPC handlers. BillingPanel.tsx gains a balance refresh callback mechanism for credit updates. Changes
Sequence DiagramsequenceDiagram
participant User as User
participant PayAsYouGo as PayAsYouGoCard
participant API as creditsApi
participant RPC as Billing RPC
participant Backend as Backend Service
User->>PayAsYouGo: Enter coupon code & click redeem
PayAsYouGo->>PayAsYouGo: Validate & trim code
PayAsYouGo->>API: redeemCoupon(code)
API->>RPC: Call billing_redeem_coupon
RPC->>Backend: POST /coupons/redeem
Backend-->>RPC: Success with amountUsd
RPC-->>API: RpcOutcome result
API-->>PayAsYouGo: CouponRedeemResult
PayAsYouGo->>PayAsYouGo: Show success message
PayAsYouGo->>API: onBalanceRefresh()
API->>RPC: Call billing_get_balance
RPC->>Backend: GET /balance
Backend-->>RPC: Balance data
RPC-->>API: RpcOutcome result
API-->>PayAsYouGo: Updated creditBalance
PayAsYouGo->>User: Display refreshed balance
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (2)
app/src/components/settings/panels/BillingPanel.tsx (1)
64-71: Consider derivingarDirtyduring render instead of inuseEffect.While this pattern works, computing derived state in
useEffectadds an extra render cycle. ThearDirtyflag could be computed directly during render or memoized withuseMemo:♻️ Alternative using useMemo
- const [arDirty, setArDirty] = useState(false); - - // Recompute dirty flag whenever local settings or server settings change - useEffect(() => { - if (!arSettings) return; - setArDirty( - arThreshold !== arSettings.thresholdUsd || - arAmount !== arSettings.rechargeAmountUsd || - arWeeklyLimit !== arSettings.weeklyLimitUsd - ); - }, [arThreshold, arAmount, arWeeklyLimit, arSettings]); + const arDirty = useMemo(() => { + if (!arSettings) return false; + return ( + arThreshold !== arSettings.thresholdUsd || + arAmount !== arSettings.rechargeAmountUsd || + arWeeklyLimit !== arSettings.weeklyLimitUsd + ); + }, [arThreshold, arAmount, arWeeklyLimit, arSettings]);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/src/components/settings/panels/BillingPanel.tsx` around lines 64 - 71, The useEffect that sets arDirty introduces an extra render; remove the effect and compute arDirty during render (or memoize it) instead of managing it via setArDirty. Replace the effect that references useEffect, setArDirty and arSettings with a derived constant like const arDirty = arSettings && (arThreshold !== arSettings.thresholdUsd || arAmount !== arSettings.rechargeAmountUsd || arWeeklyLimit !== arSettings.weeklyLimitUsd) or wrap that expression in useMemo with dependencies [arThreshold, arAmount, arWeeklyLimit, arSettings] so you can delete the useEffect and the setArDirty state updates.app/src/components/settings/panels/billing/SubscriptionPlans.tsx (1)
196-209: Consider forcingbillingIntervalto'annual'when crypto is toggled on.When
paymentMethodchanges to'crypto', the monthly button becomes disabled (line 41), but if the user was previously on'monthly', the interval state isn't automatically updated here. The parentBillingPanelhas auseEffect(lines 111-115) that handles this, but it might be cleaner to handle it in the toggle handler or document this dependency clearly.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/src/components/settings/panels/billing/SubscriptionPlans.tsx` around lines 196 - 209, The toggle handler for paymentMethod (the onClick that calls setPaymentMethod) should also force the billingInterval to 'annual' when switching to 'crypto' to avoid a stale 'monthly' state; update the handler to call setBillingInterval('annual') whenever paymentMethod becomes 'crypto' (use the same toggle logic referencing paymentMethod and setPaymentMethod, and the billingInterval/setBillingInterval props/state used by SubscriptionPlans), and keep/mention the existing BillingPanel useEffect only if you want to preserve redundancy or remove it to avoid duplicate updates.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@app/src/components/settings/panels/billing/InferenceBudget.tsx`:
- Around line 26-31: The progress calculation uses teamUsage.remainingUsd /
teamUsage.cycleBudgetUsd which can divide by zero; compute a safeRatio first
(e.g., if teamUsage.cycleBudgetUsd <= 0 then safeRatio = 0 else safeRatio =
teamUsage.remainingUsd / teamUsage.cycleBudgetUsd) and use safeRatio for both
the color ternary and the width style (width: `${Math.min(100, safeRatio *
100)}%`) so Infinity/NaN are avoided and the progress bar renders correctly;
update references in this component (InferenceBudget, teamUsage.remainingUsd,
teamUsage.cycleBudgetUsd) to use that safeRatio.
In `@app/src/components/settings/panels/billing/PayAsYouGoCard.tsx`:
- Around line 37-47: The handler assumes redeemCoupon succeeded without checking
the API response; update the block that calls creditsApi.redeemCoupon (and
inspects result) to first verify result.success (from CouponRedeemResult) before
treating it as successful: if result.success is true, proceed to compute amount,
call setCouponSuccess, clear via setCouponCode('') and call onBalanceRefresh();
if false, set an error message (e.g., via setCouponSuccess or a new error state)
and do not clear the code or call onBalanceRefresh; also log the failure with
log('[coupon] redeem failed result=%o', result) to aid debugging.
---
Nitpick comments:
In `@app/src/components/settings/panels/billing/SubscriptionPlans.tsx`:
- Around line 196-209: The toggle handler for paymentMethod (the onClick that
calls setPaymentMethod) should also force the billingInterval to 'annual' when
switching to 'crypto' to avoid a stale 'monthly' state; update the handler to
call setBillingInterval('annual') whenever paymentMethod becomes 'crypto' (use
the same toggle logic referencing paymentMethod and setPaymentMethod, and the
billingInterval/setBillingInterval props/state used by SubscriptionPlans), and
keep/mention the existing BillingPanel useEffect only if you want to preserve
redundancy or remove it to avoid duplicate updates.
In `@app/src/components/settings/panels/BillingPanel.tsx`:
- Around line 64-71: The useEffect that sets arDirty introduces an extra render;
remove the effect and compute arDirty during render (or memoize it) instead of
managing it via setArDirty. Replace the effect that references useEffect,
setArDirty and arSettings with a derived constant like const arDirty =
arSettings && (arThreshold !== arSettings.thresholdUsd || arAmount !==
arSettings.rechargeAmountUsd || arWeeklyLimit !== arSettings.weeklyLimitUsd) or
wrap that expression in useMemo with dependencies [arThreshold, arAmount,
arWeeklyLimit, arSettings] so you can delete the useEffect and the setArDirty
state updates.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: b4018f49-4b68-4e1d-ae16-eebffd2dc33d
📒 Files selected for processing (8)
app/src/components/settings/panels/BillingPanel.tsxapp/src/components/settings/panels/billing/AutoRechargeSection.tsxapp/src/components/settings/panels/billing/InferenceBudget.tsxapp/src/components/settings/panels/billing/PayAsYouGoCard.tsxapp/src/components/settings/panels/billing/SubscriptionPlans.tsxapp/src/services/api/creditsApi.tssrc/openhuman/billing/ops.rssrc/openhuman/billing/schemas.rs
| : teamUsage.remainingUsd / teamUsage.cycleBudgetUsd < 0.2 | ||
| ? 'bg-amber-500' | ||
| : 'bg-primary-500' | ||
| }`} | ||
| style={{ | ||
| width: `${Math.min(100, (teamUsage.remainingUsd / teamUsage.cycleBudgetUsd) * 100)}%`, |
There was a problem hiding this comment.
Potential division by zero when cycleBudgetUsd is 0.
If cycleBudgetUsd is ever 0 (e.g., edge case with a misconfigured plan or API returning unexpected data), the division will produce Infinity or NaN, causing the progress bar width calculation to break.
🛡️ Proposed defensive fix
- className={`h-full rounded-full transition-all duration-300 ${
- teamUsage.remainingUsd <= 0
- ? 'bg-coral-500'
- : teamUsage.remainingUsd / teamUsage.cycleBudgetUsd < 0.2
- ? 'bg-amber-500'
- : 'bg-primary-500'
- }`}
- style={{
- width: `${Math.min(100, (teamUsage.remainingUsd / teamUsage.cycleBudgetUsd) * 100)}%`,
- }}
+ className={`h-full rounded-full transition-all duration-300 ${
+ teamUsage.remainingUsd <= 0
+ ? 'bg-coral-500'
+ : teamUsage.cycleBudgetUsd > 0 &&
+ teamUsage.remainingUsd / teamUsage.cycleBudgetUsd < 0.2
+ ? 'bg-amber-500'
+ : 'bg-primary-500'
+ }`}
+ style={{
+ width: `${teamUsage.cycleBudgetUsd > 0 ? Math.min(100, (teamUsage.remainingUsd / teamUsage.cycleBudgetUsd) * 100) : 0}%`,
+ }}📝 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.
| : teamUsage.remainingUsd / teamUsage.cycleBudgetUsd < 0.2 | |
| ? 'bg-amber-500' | |
| : 'bg-primary-500' | |
| }`} | |
| style={{ | |
| width: `${Math.min(100, (teamUsage.remainingUsd / teamUsage.cycleBudgetUsd) * 100)}%`, | |
| className={`h-full rounded-full transition-all duration-300 ${ | |
| teamUsage.remainingUsd <= 0 | |
| ? 'bg-coral-500' | |
| : teamUsage.cycleBudgetUsd > 0 && | |
| teamUsage.remainingUsd / teamUsage.cycleBudgetUsd < 0.2 | |
| ? 'bg-amber-500' | |
| : 'bg-primary-500' | |
| }`} | |
| style={{ | |
| width: `${teamUsage.cycleBudgetUsd > 0 ? Math.min(100, (teamUsage.remainingUsd / teamUsage.cycleBudgetUsd) * 100) : 0}%`, | |
| }} |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@app/src/components/settings/panels/billing/InferenceBudget.tsx` around lines
26 - 31, The progress calculation uses teamUsage.remainingUsd /
teamUsage.cycleBudgetUsd which can divide by zero; compute a safeRatio first
(e.g., if teamUsage.cycleBudgetUsd <= 0 then safeRatio = 0 else safeRatio =
teamUsage.remainingUsd / teamUsage.cycleBudgetUsd) and use safeRatio for both
the color ternary and the width style (width: `${Math.min(100, safeRatio *
100)}%`) so Infinity/NaN are avoided and the progress bar renders correctly;
update references in this component (InferenceBudget, teamUsage.remainingUsd,
teamUsage.cycleBudgetUsd) to use that safeRatio.
| try { | ||
| log('[coupon] redeeming code=%s', code); | ||
| const result = await creditsApi.redeemCoupon(code); | ||
| const amount = result?.data?.amountUsd; | ||
| setCouponSuccess( | ||
| amount != null | ||
| ? `Coupon redeemed! $${amount.toFixed(2)} added to your credits.` | ||
| : 'Coupon redeemed successfully!' | ||
| ); | ||
| setCouponCode(''); | ||
| onBalanceRefresh(); |
There was a problem hiding this comment.
Consider checking result.success before treating as successful redemption.
The CouponRedeemResult type includes a success: boolean field, but the handler doesn't check it. If the backend returns { success: false, ... } with a 200 status, this would incorrectly show a success message.
🛡️ Proposed fix to check success flag
try {
log('[coupon] redeeming code=%s', code);
const result = await creditsApi.redeemCoupon(code);
+ if (!result.success) {
+ throw new Error('Coupon redemption failed');
+ }
const amount = result?.data?.amountUsd;
setCouponSuccess(
amount != null📝 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.
| try { | |
| log('[coupon] redeeming code=%s', code); | |
| const result = await creditsApi.redeemCoupon(code); | |
| const amount = result?.data?.amountUsd; | |
| setCouponSuccess( | |
| amount != null | |
| ? `Coupon redeemed! $${amount.toFixed(2)} added to your credits.` | |
| : 'Coupon redeemed successfully!' | |
| ); | |
| setCouponCode(''); | |
| onBalanceRefresh(); | |
| try { | |
| log('[coupon] redeeming code=%s', code); | |
| const result = await creditsApi.redeemCoupon(code); | |
| if (!result.success) { | |
| throw new Error('Coupon redemption failed'); | |
| } | |
| const amount = result?.data?.amountUsd; | |
| setCouponSuccess( | |
| amount != null | |
| ? `Coupon redeemed! $${amount.toFixed(2)} added to your credits.` | |
| : 'Coupon redeemed successfully!' | |
| ); | |
| setCouponCode(''); | |
| onBalanceRefresh(); |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@app/src/components/settings/panels/billing/PayAsYouGoCard.tsx` around lines
37 - 47, The handler assumes redeemCoupon succeeded without checking the API
response; update the block that calls creditsApi.redeemCoupon (and inspects
result) to first verify result.success (from CouponRedeemResult) before treating
it as successful: if result.success is true, proceed to compute amount, call
setCouponSuccess, clear via setCouponCode('') and call onBalanceRefresh(); if
false, set an error message (e.g., via setCouponSuccess or a new error state)
and do not clear the code or call onBalanceRefresh; also log the failure with
log('[coupon] redeem failed result=%o', result) to aid debugging.
…humansai#337) * Refactor BillingPanel and introduce new billing components - Refactored the BillingPanel component to streamline its structure and improve readability by removing unused constants and functions. - Introduced new components: AutoRechargeSection, InferenceBudget, PayAsYouGoCard, and SubscriptionPlans to modularize billing functionalities. - Added coupon redemption functionality in the credits API, allowing users to redeem coupon codes for credits. - Enhanced the overall billing experience by improving the layout and organization of billing-related UI elements. * style: apply prettier formatting to billing components Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Summary
Changes
Rust Core (
src/openhuman/billing/)redeem_coupon()andget_user_coupons()ops callingPOST /coupons/redeemandGET /coupons/mebilling_redeem_couponandbilling_get_couponsRPC schemas + handlersFrontend Service (
app/src/services/api/creditsApi.ts)CouponRedeemResultandRedeemedCoupontypesredeemCoupon()andgetUserCoupons()API methodsUI Redesign (
app/src/components/settings/panels/)billing/PayAsYouGoCard.tsx— Credit balances, top-up buttons, coupon code input with redeem flowbilling/SubscriptionPlans.tsx— Monthly/annual toggle, plan tier cards, crypto togglebilling/InferenceBudget.tsx— Usage stats with progress barbilling/AutoRechargeSection.tsx— Auto-recharge settings + payment methodsBillingPanel.tsx— Slimmed from ~1173 to ~350 lines; composes sub-components in new layout orderNew Layout Order
Test plan
cargo checkpasses (verified)yarn typecheckpasses (verified)yarn lintpasses with 0 errors (verified)🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Refactor