Skip to content

Redesign billing: pay-as-you-go first-class + coupon redemption#337

Merged
senamakel merged 2 commits into
tinyhumansai:mainfrom
senamakel:feat/payments
Apr 6, 2026
Merged

Redesign billing: pay-as-you-go first-class + coupon redemption#337
senamakel merged 2 commits into
tinyhumansai:mainfrom
senamakel:feat/payments

Conversation

@senamakel
Copy link
Copy Markdown
Member

@senamakel senamakel commented Apr 6, 2026

Summary

  • Elevate pay-as-you-go credits as a first-class billing mode, positioned at the top of the billing page instead of buried as an "overflow" feature
  • Add coupon redemption flow end-to-end: Rust core RPC → frontend service → UI input
  • Decompose the 1173-line BillingPanel into 4 focused sub-components for maintainability

Changes

Rust Core (src/openhuman/billing/)

  • Added redeem_coupon() and get_user_coupons() ops calling POST /coupons/redeem and GET /coupons/me
  • Wired billing_redeem_coupon and billing_get_coupons RPC schemas + handlers

Frontend Service (app/src/services/api/creditsApi.ts)

  • Added CouponRedeemResult and RedeemedCoupon types
  • Added redeemCoupon() and getUserCoupons() API methods

UI Redesign (app/src/components/settings/panels/)

  • New billing/PayAsYouGoCard.tsx — Credit balances, top-up buttons, coupon code input with redeem flow
  • New billing/SubscriptionPlans.tsx — Monthly/annual toggle, plan tier cards, crypto toggle
  • New billing/InferenceBudget.tsx — Usage stats with progress bar
  • New billing/AutoRechargeSection.tsx — Auto-recharge settings + payment methods
  • Restructured BillingPanel.tsx — Slimmed from ~1173 to ~350 lines; composes sub-components in new layout order

New Layout Order

  1. Current Plan header
  2. Pay as You Go (promoted to top — credits + top-up + coupon input)
  3. Divider: "Or subscribe for included usage + discounts"
  4. Subscription plans (monthly/annual toggle + plan cards)
  5. Inference budget
  6. Auto-recharge + payment methods
  7. "Why upgrade?" box

Test plan

  • cargo check passes (verified)
  • yarn typecheck passes (verified)
  • yarn lint passes with 0 errors (verified)
  • Manual test: Settings → Billing shows pay-as-you-go card at top
  • Manual test: Coupon input → redeem → balance refreshes
  • Manual test: Top-up buttons still open Stripe checkout
  • Manual test: Monthly/annual toggle + plan upgrade flow works
  • Manual test: Auto-recharge toggle and card management works

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added coupon redemption functionality to the billing section, allowing users to enter and redeem coupon codes for credits.
  • Refactor

    • Reorganized billing settings panel UI into modular components for improved maintainability and clarity.

senamakel and others added 2 commits April 5, 2026 20:57
- 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>
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 6, 2026

📝 Walkthrough

Walkthrough

The 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

Cohort / File(s) Summary
Billing Panel Refactoring
app/src/components/settings/panels/BillingPanel.tsx
Removed ~694 lines of inline JSX and logic for pay-as-you-go, subscription plans, inference budget, and auto-recharge UI. Added handleBalanceRefresh callback that fetches updated credit balance. Delegates rendering to four new child components.
New Billing UI Components
app/src/components/settings/panels/billing/PayAsYouGoCard.tsx, SubscriptionPlans.tsx, InferenceBudget.tsx, AutoRechargeSection.tsx
Four new components extracted from BillingPanel: PayAsYouGoCard renders pay-as-you-go balance and coupon redemption; SubscriptionPlans displays tier cards with upgrade flows; InferenceBudget shows usage metrics and progress; AutoRechargeSection manages auto-recharge settings and payment method management.
Credits API Expansion
app/src/services/api/creditsApi.ts
Added CouponRedeemResult and RedeemedCoupon interfaces. Added async methods redeemCoupon(code) and getUserCoupons() that wrap RPC endpoints for coupon operations.
Rust Billing RPC Operations
src/openhuman/billing/ops.rs
Added redeem_coupon(config, code) function that performs authenticated POST to /coupons/redeem. Added get_user_coupons(config) function that performs authenticated GET to /coupons/me.
Rust Billing Schema & Handlers
src/openhuman/billing/schemas.rs
Registered two new RPC controller functions: billing_redeem_coupon and billing_get_coupons. Implemented corresponding handlers with input/output JSON schema contracts. Added RedeemCouponParams deserialization struct.

Sequence Diagram

sequenceDiagram
    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
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Poem

🐰 Refactor, refactor, components so neat,
Extract and extract, make billing complete!
With coupons redeemed and balance refreshed,
The billing panel's elegantly meshed! ✨🎉

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 54.55% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the primary changes: promoting pay-as-you-go as first-class and adding coupon redemption—the two main objectives evident across the refactored billing UI and new API endpoints.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (2)
app/src/components/settings/panels/BillingPanel.tsx (1)

64-71: Consider deriving arDirty during render instead of in useEffect.

While this pattern works, computing derived state in useEffect adds an extra render cycle. The arDirty flag could be computed directly during render or memoized with useMemo:

♻️ 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 forcing billingInterval to 'annual' when crypto is toggled on.

When paymentMethod changes 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 parent BillingPanel has a useEffect (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

📥 Commits

Reviewing files that changed from the base of the PR and between 7bce9d2 and 908b407.

📒 Files selected for processing (8)
  • app/src/components/settings/panels/BillingPanel.tsx
  • app/src/components/settings/panels/billing/AutoRechargeSection.tsx
  • app/src/components/settings/panels/billing/InferenceBudget.tsx
  • app/src/components/settings/panels/billing/PayAsYouGoCard.tsx
  • app/src/components/settings/panels/billing/SubscriptionPlans.tsx
  • app/src/services/api/creditsApi.ts
  • src/openhuman/billing/ops.rs
  • src/openhuman/billing/schemas.rs

Comment on lines +26 to +31
: teamUsage.remainingUsd / teamUsage.cycleBudgetUsd < 0.2
? 'bg-amber-500'
: 'bg-primary-500'
}`}
style={{
width: `${Math.min(100, (teamUsage.remainingUsd / teamUsage.cycleBudgetUsd) * 100)}%`,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

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.

Suggested change
: 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.

Comment on lines +37 to +47
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();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

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.

Suggested change
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.

@senamakel senamakel merged commit 09b627c into tinyhumansai:main Apr 6, 2026
8 of 9 checks passed
@coderabbitai coderabbitai Bot mentioned this pull request Apr 10, 2026
5 tasks
AusAgentSmith pushed a commit to AusAgentSmith/openhuman that referenced this pull request May 23, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant