diff --git a/app/src/components/settings/panels/BillingPanel.tsx b/app/src/components/settings/panels/BillingPanel.tsx index cb9e4db7f..5dfc216e2 100644 --- a/app/src/components/settings/panels/BillingPanel.tsx +++ b/app/src/components/settings/panels/BillingPanel.tsx @@ -14,36 +14,13 @@ import type { CurrentPlanData, PlanTier } from '../../../types/api'; import { openUrl } from '../../../utils/openUrl'; import SettingsHeader from '../components/SettingsHeader'; import { useSettingsNavigation } from '../hooks/useSettingsNavigation'; -import { - annualSavings, - buildPlanId, - isUpgrade as checkIsUpgrade, - displayPrice, - formatStorageLimit, - formatUsdAmount, - getPlanMeta, - PLANS, -} from './billingHelpers'; +import AutoRechargeSection from './billing/AutoRechargeSection'; +import InferenceBudget from './billing/InferenceBudget'; +import PayAsYouGoCard from './billing/PayAsYouGoCard'; +import SubscriptionPlans from './billing/SubscriptionPlans'; +import { buildPlanId, formatStorageLimit, formatUsdAmount, getPlanMeta } from './billingHelpers'; -// ── Constants ──────────────────────────────────────────────────────────────── const log = createDebug('openhuman:billing-panel'); -const THRESHOLD_OPTIONS = [5, 10, 20] as const; -const RECHARGE_OPTIONS = [10, 20, 50, 100] as const; -const WEEKLY_LIMIT_OPTIONS = [25, 50, 100, 200, 500] as const; - -const CARD_BRAND_LABELS: Record = { - visa: 'Visa', - mastercard: 'Mastercard', - amex: 'Amex', - discover: 'Discover', - jcb: 'JCB', - diners: 'Diners', - unionpay: 'UnionPay', -}; - -function cardBrandLabel(brand: string) { - return CARD_BRAND_LABELS[brand.toLowerCase()] ?? brand.charAt(0).toUpperCase() + brand.slice(1); -} // ── Component ─────────────────────────────────────────────────────────── const BillingPanel = () => { @@ -181,7 +158,6 @@ const BillingPanel = () => { if (!arSettings || arSaving) return; const nextEnabled = !arSettings.enabled; - // Prevent enabling without a saved card if (nextEnabled && !arSettings.hasSavedPaymentMethod && cards.length === 0) { setArError('Add a payment card before enabling auto-recharge.'); return; @@ -247,7 +223,6 @@ const BillingPanel = () => { try { const updated = await creditsApi.deleteCard(paymentMethodId); setCards(updated.cards); - // Refresh auto-recharge settings (hasSavedPaymentMethod may change) const refreshed = await creditsApi.getAutoRecharge(); setArSettings(refreshed); } catch { @@ -258,8 +233,6 @@ const BillingPanel = () => { }; const handleAddCard = async () => { - // Card setup requires Stripe.js confirmation. - // Use the Stripe Customer Portal as the secure entry point. try { const { portalUrl } = await billingApi.createPortalSession(); await openUrl(portalUrl); @@ -271,7 +244,6 @@ const BillingPanel = () => { // Handle payment:success deep link event useEffect(() => { const onPaymentSuccess = async () => { - // Stop any in-flight poll — we know checkout completed if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; @@ -280,7 +252,6 @@ const BillingPanel = () => { setPurchasingTier(null); setPaymentConfirmed(true); - // Fetch current plan from backend, then refresh user/teams in store try { const plan = await billingApi.getCurrentPlan(); log('[payment-success] plan=%s active=%s', plan.plan, plan.hasActiveSubscription); @@ -290,7 +261,6 @@ const BillingPanel = () => { } await refresh(); - // Auto-hide the success banner after 5 s timeoutRef.current = window.setTimeout(() => setPaymentConfirmed(false), 5_000); }; @@ -315,7 +285,6 @@ const BillingPanel = () => { pollStartRef.current = Date.now(); pollRef.current = setInterval(async () => { - // Stop after 2 minutes if (Date.now() - pollStartRef.current > 120_000) { if (pollRef.current) clearInterval(pollRef.current); setIsPurchasing(false); @@ -386,6 +355,15 @@ const BillingPanel = () => { } }; + const handleBalanceRefresh = useCallback(async () => { + try { + const balance = await creditsApi.getBalance(); + setCreditBalance(balance); + } catch (err) { + log('[balance-refresh] failed: %O', err); + } + }, []); + // ── JSX ───────────────────────────────────────────────────────────── return (
@@ -395,7 +373,6 @@ const BillingPanel = () => { onBack={navigateBack} /> - {/*
*/}
@@ -452,671 +429,72 @@ const BillingPanel = () => { )}
- {/* ── Inference Budget (Team Usage) ─────────────────────── */} -
-
-

Inference Budget

- {isLoadingCredits && Loading…} - {teamUsage && !isLoadingCredits && ( - - ${teamUsage.remainingUsd.toFixed(2)} / ${teamUsage.cycleBudgetUsd.toFixed(2)}{' '} - remaining - - )} -
- {teamUsage ? ( - <> -
-
-
-
- - Daily usage: ${teamUsage.dailyUsage.toFixed(3)} - - - {( - (teamUsage.totalInputTokensThisCycle + - teamUsage.totalOutputTokensThisCycle) / - 1000 - ).toFixed(1)} - k tokens this cycle - -
-
- - 5-hour cap: ${teamUsage.fiveHourSpendUsd.toFixed(2)} / $ - {teamUsage.fiveHourCapUsd.toFixed(2)} - - - Cycle ends {new Date(teamUsage.cycleEndsAt).toLocaleDateString('en-US')} - -
- {teamUsage.remainingUsd <= 0 && ( -

- Included subscription usage is exhausted. Top up credits to continue using AI - features without waiting for the next cycle. -

- )} - - ) : isLoadingCredits ? ( -
- ) : ( -

Unable to load usage data

- )} -
- - {/* ── Credits Balance & Top-up ──────────────────────────── */} -
-

Pay-as-You-Go Credits

- {creditBalance ? ( -
-
- General credits - - ${creditBalance.balanceUsd.toFixed(2)} - -
-
-
- Top-up credits - - ${creditBalance.topUpBalanceUsd.toFixed(2)} - {creditBalance.topUpBaselineUsd != null && - creditBalance.topUpBaselineUsd > 0 && ( - - {' '} - / ${creditBalance.topUpBaselineUsd.toFixed(2)} - - )} - -
- {creditBalance.topUpBaselineUsd != null && - creditBalance.topUpBaselineUsd > 0 && ( -
-
-
- )} -
-
- ) : isLoadingCredits ? ( -
-
-
-
- ) : ( -

Unable to load balance

- )} -

- Subscription usage is consumed first. Top-up credits are reserved for overflow - inference, bandwidth, and integration usage. -

-
- {[5, 10, 25].map(amount => ( - - ))} -
-
+ {/* ── Pay as You Go ── PROMOTED TO TOP ─────────────────── */} +
- {/* ── Interval toggle ──────────────────────────────────── */} -
- - + {/* ── Divider ──────────────────────────────────────────── */} +
+
+ + Or subscribe for included usage + discounts + +
- {/* ── Plan tier cards ───────────────────────────────────── */} -
- {PLANS.map(plan => { - const isCurrent = plan.tier === currentTier; - const isUpgrade = checkIsUpgrade(plan.tier, currentTier); - const savings = annualSavings(plan, billingInterval); - const isThisPurchasing = isPurchasing && purchasingTier === plan.tier; + {/* ── Subscription Plans ──────────────────────────────── */} + - return ( -
-
-
-
-

{plan.name}

- {/* Features inline with title */} - {plan.features.map(f => ( - - - {f.text} - - ))} - {isCurrent && ( - - Current - - )} - {savings && ( - - Save {savings}% - - )} -
-
- - {displayPrice(plan, billingInterval)} - - {plan.tier !== 'FREE' && ( - /mo - )} - {plan.tier !== 'FREE' && billingInterval === 'annual' && ( - - (billed ${plan.annualPrice}/yr) - - )} -
-
- - Included monthly value: {formatUsdAmount(plan.monthlyBudgetUsd)} - - - 7-day cycle: {formatUsdAmount(plan.weeklyBudgetUsd)} - - - 5-hour cap: {formatUsdAmount(plan.fiveHourCapUsd)} - - - Discount: {plan.discountPercent}% - - - Storage: {formatStorageLimit(plan.storageLimitBytes)} - -
-
- - {/* Action button */} - {isUpgrade && ( - - )} -
-
- ); - })} -
- - {/* ── Payment confirmed banner ─────────────────────────── */} - {paymentConfirmed && ( -
-
- - - -

- Payment confirmed! Your plan has been updated. -

-
-
- )} - - {/* ── Purchasing overlay message ────────────────────────── */} - {isPurchasing && ( -
-
- - - - -

- Waiting for payment confirmation... Complete checkout in the browser window that - opened. -

-
-
- )} - - {/* ── Pay with crypto toggle ────────────────────────────── */} -
-
-

Pay with Crypto

-

- You can choose to pay annually using crypto -

-
- -
- - {/* ── Auto-Recharge Credits ─────────────────────────────── */} + {/* ── Inference Budget ────────────────────────────────── */}
-
- {/* Header row */} -
-
-

Auto-Recharge Credits

-

- Automatically top up when your balance runs low -

-
- {arLoading ? ( -
- ) : ( - - )} -
- - {/* Error banner */} - {arError && ( -
- - - -

{arError}

- -
- )} - - {/* Settings — only shown when enabled */} - {!arLoading && arSettings?.enabled && ( -
- {/* Status row */} -
- {arSettings.inFlight && ( - - - - - - Recharge in progress - - )} - {arSettings.spentThisWeekUsd > 0 && ( - - ${arSettings.spentThisWeekUsd.toFixed(2)} of ${arSettings.weeklyLimitUsd}{' '} - used this week - - )} - {arSettings.lastRechargeAt && ( - - Last recharged{' '} - {new Date(arSettings.lastRechargeAt).toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - })} - - )} -
- - {/* Last error from recharge attempt */} - {arSettings.lastError && ( -
- - - -

- Last recharge failed: {arSettings.lastError} -

-
- )} - - {/* Trigger threshold */} -
-

- Recharge when balance drops below -

-
- {THRESHOLD_OPTIONS.map(v => ( - - ))} -
-
- - {/* Recharge amount */} -
-

Add this amount

-
- {RECHARGE_OPTIONS.map(v => ( - - ))} -
-
- - {/* Weekly limit */} -
-

Weekly spending limit

-
- {WEEKLY_LIMIT_OPTIONS.map(v => ( - - ))} -
-
- - {/* Validation hint */} - {arAmount <= arThreshold && ( -

- Recharge amount should be greater than the trigger threshold. -

- )} - - {/* Save button */} - {arDirty && ( - - )} -
- )} - - {/* Payment methods */} -
-
-

Payment Methods

- -
- - {cardsLoading ? ( -
- {[0, 1].map(i => ( -
- ))} -
- ) : cards.length === 0 ? ( -
- - - -

- No saved cards. Add one to enable auto-recharge. -

-
- ) : ( -
- {cards.map(card => { - const isDeleting = deletingCardId === card.id; - const isSettingDefault = settingDefaultId === card.id; - const isConfirming = confirmDeleteId === card.id; - - return ( -
- {/* Card icon */} - - - - - {/* Card info */} -
-
- - {cardBrandLabel(card.brand)} ••••{card.last4} - - {card.isDefault && ( - - Default - - )} -
-

- Expires {String(card.expMonth).padStart(2, '0')}/ - {String(card.expYear).slice(-2)} -

-
- - {/* Actions */} -
- {!card.isDefault && ( - - )} - - {isConfirming ? ( -
- - -
- ) : ( - - )} -
-
- ); - })} -
- )} -
-
+
- {/* ── Upgrade benefits ───────────────────────────────────── */} + {/* ── Auto-Recharge + Payment Methods ────────────────── */} + + + {/* ── Why upgrade? ───────────────────────────────────── */}

Why upgrade?

diff --git a/app/src/components/settings/panels/billing/AutoRechargeSection.tsx b/app/src/components/settings/panels/billing/AutoRechargeSection.tsx new file mode 100644 index 000000000..e12dd23cb --- /dev/null +++ b/app/src/components/settings/panels/billing/AutoRechargeSection.tsx @@ -0,0 +1,402 @@ +import type { AutoRechargeSettings, SavedCard } from '../../../../services/api/creditsApi'; + +// ── Constants ──────────────────────────────────────────────────────────────── +const THRESHOLD_OPTIONS = [5, 10, 20] as const; +const RECHARGE_OPTIONS = [10, 20, 50, 100] as const; +const WEEKLY_LIMIT_OPTIONS = [25, 50, 100, 200, 500] as const; + +const CARD_BRAND_LABELS: Record = { + visa: 'Visa', + mastercard: 'Mastercard', + amex: 'Amex', + discover: 'Discover', + jcb: 'JCB', + diners: 'Diners', + unionpay: 'UnionPay', +}; + +function cardBrandLabel(brand: string) { + return CARD_BRAND_LABELS[brand.toLowerCase()] ?? brand.charAt(0).toUpperCase() + brand.slice(1); +} + +interface AutoRechargeSectionProps { + arSettings: AutoRechargeSettings | null; + arLoading: boolean; + arError: string | null; + arSaving: boolean; + arThreshold: number; + arAmount: number; + arWeeklyLimit: number; + arDirty: boolean; + setArThreshold: (v: number) => void; + setArAmount: (v: number) => void; + setArWeeklyLimit: (v: number) => void; + setArError: (v: string | null) => void; + onArToggle: () => void; + onArSave: () => void; + // Cards + cards: SavedCard[]; + cardsLoading: boolean; + confirmDeleteId: string | null; + deletingCardId: string | null; + settingDefaultId: string | null; + setConfirmDeleteId: (v: string | null) => void; + onSetDefault: (paymentMethodId: string) => void; + onDeleteCard: (paymentMethodId: string) => void; + onAddCard: () => void; +} + +const AutoRechargeSection = ({ + arSettings, + arLoading, + arError, + arSaving, + arThreshold, + arAmount, + arWeeklyLimit, + arDirty, + setArThreshold, + setArAmount, + setArWeeklyLimit, + setArError, + onArToggle, + onArSave, + cards, + cardsLoading, + confirmDeleteId, + deletingCardId, + settingDefaultId, + setConfirmDeleteId, + onSetDefault, + onDeleteCard, + onAddCard, +}: AutoRechargeSectionProps) => ( +
+
+ {/* Header row */} +
+
+

Auto-Recharge Credits

+

+ Automatically top up when your balance runs low +

+
+ {arLoading ? ( +
+ ) : ( + + )} +
+ + {/* Error banner */} + {arError && ( +
+ + + +

{arError}

+ +
+ )} + + {/* Settings — only shown when enabled */} + {!arLoading && arSettings?.enabled && ( +
+ {/* Status row */} +
+ {arSettings.inFlight && ( + + + + + + Recharge in progress + + )} + {arSettings.spentThisWeekUsd > 0 && ( + + ${arSettings.spentThisWeekUsd.toFixed(2)} of ${arSettings.weeklyLimitUsd} used this + week + + )} + {arSettings.lastRechargeAt && ( + + Last recharged{' '} + {new Date(arSettings.lastRechargeAt).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + })} + + )} +
+ + {/* Last error from recharge attempt */} + {arSettings.lastError && ( +
+ + + +

+ Last recharge failed: {arSettings.lastError} +

+
+ )} + + {/* Trigger threshold */} +
+

Recharge when balance drops below

+
+ {THRESHOLD_OPTIONS.map(v => ( + + ))} +
+
+ + {/* Recharge amount */} +
+

Add this amount

+
+ {RECHARGE_OPTIONS.map(v => ( + + ))} +
+
+ + {/* Weekly limit */} +
+

Weekly spending limit

+
+ {WEEKLY_LIMIT_OPTIONS.map(v => ( + + ))} +
+
+ + {/* Validation hint */} + {arAmount <= arThreshold && ( +

+ Recharge amount should be greater than the trigger threshold. +

+ )} + + {/* Save button */} + {arDirty && ( + + )} +
+ )} + + {/* Payment methods */} +
+
+

Payment Methods

+ +
+ + {cardsLoading ? ( +
+ {[0, 1].map(i => ( +
+ ))} +
+ ) : cards.length === 0 ? ( +
+ + + +

+ No saved cards. Add one to enable auto-recharge. +

+
+ ) : ( +
+ {cards.map(card => { + const isDeleting = deletingCardId === card.id; + const isSettingDefault = settingDefaultId === card.id; + const isConfirming = confirmDeleteId === card.id; + + return ( +
+ {/* Card icon */} + + + + + {/* Card info */} +
+
+ + {cardBrandLabel(card.brand)} ••••{card.last4} + + {card.isDefault && ( + + Default + + )} +
+

+ Expires {String(card.expMonth).padStart(2, '0')}/ + {String(card.expYear).slice(-2)} +

+
+ + {/* Actions */} +
+ {!card.isDefault && ( + + )} + + {isConfirming ? ( +
+ + +
+ ) : ( + + )} +
+
+ ); + })} +
+ )} +
+
+
+); + +export default AutoRechargeSection; diff --git a/app/src/components/settings/panels/billing/InferenceBudget.tsx b/app/src/components/settings/panels/billing/InferenceBudget.tsx new file mode 100644 index 000000000..6c8006ef6 --- /dev/null +++ b/app/src/components/settings/panels/billing/InferenceBudget.tsx @@ -0,0 +1,71 @@ +import type { TeamUsage } from '../../../../services/api/creditsApi'; + +interface InferenceBudgetProps { + teamUsage: TeamUsage | null; + isLoadingCredits: boolean; +} + +const InferenceBudget = ({ teamUsage, isLoadingCredits }: InferenceBudgetProps) => ( +
+
+

Inference Budget

+ {isLoadingCredits && Loading…} + {teamUsage && !isLoadingCredits && ( + + ${teamUsage.remainingUsd.toFixed(2)} / ${teamUsage.cycleBudgetUsd.toFixed(2)} remaining + + )} +
+ {teamUsage ? ( + <> +
+
+
+
+ + Daily usage: ${teamUsage.dailyUsage.toFixed(3)} + + + {( + (teamUsage.totalInputTokensThisCycle + teamUsage.totalOutputTokensThisCycle) / + 1000 + ).toFixed(1)} + k tokens this cycle + +
+
+ + 5-hour cap: ${teamUsage.fiveHourSpendUsd.toFixed(2)} / $ + {teamUsage.fiveHourCapUsd.toFixed(2)} + + + Cycle ends {new Date(teamUsage.cycleEndsAt).toLocaleDateString('en-US')} + +
+ {teamUsage.remainingUsd <= 0 && ( +

+ Included subscription usage is exhausted. Top up credits to continue using AI features + without waiting for the next cycle. +

+ )} + + ) : isLoadingCredits ? ( +
+ ) : ( +

Unable to load usage data

+ )} +
+); + +export default InferenceBudget; diff --git a/app/src/components/settings/panels/billing/PayAsYouGoCard.tsx b/app/src/components/settings/panels/billing/PayAsYouGoCard.tsx new file mode 100644 index 000000000..47a6b7629 --- /dev/null +++ b/app/src/components/settings/panels/billing/PayAsYouGoCard.tsx @@ -0,0 +1,200 @@ +import createDebug from 'debug'; +import { useState } from 'react'; + +import { type CreditBalance, creditsApi } from '../../../../services/api/creditsApi'; + +const log = createDebug('openhuman:billing-payg'); + +interface PayAsYouGoCardProps { + creditBalance: CreditBalance | null; + isLoadingCredits: boolean; + isToppingUp: boolean; + onTopUp: (amountUsd: number) => void; + onBalanceRefresh: () => void; +} + +const PayAsYouGoCard = ({ + creditBalance, + isLoadingCredits, + isToppingUp, + onTopUp, + onBalanceRefresh, +}: PayAsYouGoCardProps) => { + // Coupon state (local — no need to share with other sections) + const [couponCode, setCouponCode] = useState(''); + const [couponLoading, setCouponLoading] = useState(false); + const [couponError, setCouponError] = useState(null); + const [couponSuccess, setCouponSuccess] = useState(null); + + const handleRedeemCoupon = async () => { + const code = couponCode.trim(); + if (!code || couponLoading) return; + + setCouponLoading(true); + setCouponError(null); + setCouponSuccess(null); + + 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(); + } catch (err) { + const msg = + err && typeof err === 'object' && 'error' in err + ? String((err as { error: unknown }).error) + : 'Invalid or expired coupon code.'; + log('[coupon] error: %s', msg); + setCouponError(msg); + } finally { + setCouponLoading(false); + } + }; + + return ( +
+

Pay as You Go

+ + {/* Balance display */} + {creditBalance ? ( +
+
+ General credits + + ${creditBalance.balanceUsd.toFixed(2)} + +
+
+
+ Top-up credits + + ${creditBalance.topUpBalanceUsd.toFixed(2)} + {creditBalance.topUpBaselineUsd != null && creditBalance.topUpBaselineUsd > 0 && ( + + {' '} + / ${creditBalance.topUpBaselineUsd.toFixed(2)} + + )} + +
+ {creditBalance.topUpBaselineUsd != null && creditBalance.topUpBaselineUsd > 0 && ( +
+
+
+ )} +
+
+ ) : isLoadingCredits ? ( +
+
+
+
+ ) : ( +

Unable to load balance

+ )} + +

+ No subscription needed — buy credits as you need them. If you have a subscription, your + included budget is consumed first. +

+ + {/* Top-up buttons */} +
+ {[5, 10, 25].map(amount => ( + + ))} +
+ + {/* Coupon redemption */} +
+

Have a coupon?

+
+ { + setCouponCode(e.target.value.toUpperCase()); + if (couponError) setCouponError(null); + if (couponSuccess) setCouponSuccess(null); + }} + onKeyDown={e => { + if (e.key === 'Enter') handleRedeemCoupon(); + }} + placeholder="XXXX-XXXX" + className="flex-1 px-2.5 py-1.5 text-xs rounded-lg border border-stone-200 bg-stone-50 text-stone-900 placeholder-stone-400 focus:outline-none focus:ring-1 focus:ring-primary-500 focus:border-primary-500" + /> + +
+ + {couponSuccess && ( +
+ + + +

{couponSuccess}

+
+ )} + + {couponError && ( +
+ + + +

{couponError}

+
+ )} +
+
+ ); +}; + +export default PayAsYouGoCard; diff --git a/app/src/components/settings/panels/billing/SubscriptionPlans.tsx b/app/src/components/settings/panels/billing/SubscriptionPlans.tsx new file mode 100644 index 000000000..240b9a346 --- /dev/null +++ b/app/src/components/settings/panels/billing/SubscriptionPlans.tsx @@ -0,0 +1,213 @@ +import type { PlanTier } from '../../../../types/api'; +import { + annualSavings, + isUpgrade as checkIsUpgrade, + displayPrice, + formatStorageLimit, + formatUsdAmount, + PLANS, +} from '../billingHelpers'; + +interface SubscriptionPlansProps { + currentTier: PlanTier; + billingInterval: 'monthly' | 'annual'; + setBillingInterval: (v: 'monthly' | 'annual') => void; + paymentMethod: 'card' | 'crypto'; + setPaymentMethod: (v: 'card' | 'crypto') => void; + isPurchasing: boolean; + purchasingTier: PlanTier | null; + paymentConfirmed: boolean; + onUpgrade: (tier: PlanTier) => void; +} + +const SubscriptionPlans = ({ + currentTier, + billingInterval, + setBillingInterval, + paymentMethod, + setPaymentMethod, + isPurchasing, + purchasingTier, + paymentConfirmed, + onUpgrade, +}: SubscriptionPlansProps) => ( + <> + {/* Interval toggle */} +
+ + +
+ + {/* Plan tier cards */} +
+ {PLANS.map(plan => { + const isCurrent = plan.tier === currentTier; + const isUpgrade = checkIsUpgrade(plan.tier, currentTier); + const savings = annualSavings(plan, billingInterval); + const isThisPurchasing = isPurchasing && purchasingTier === plan.tier; + + return ( +
+
+
+
+

{plan.name}

+ {plan.features.map(f => ( + + + {f.text} + + ))} + {isCurrent && ( + + Current + + )} + {savings && ( + + Save {savings}% + + )} +
+
+ + {displayPrice(plan, billingInterval)} + + {plan.tier !== 'FREE' && /mo} + {plan.tier !== 'FREE' && billingInterval === 'annual' && ( + + (billed ${plan.annualPrice}/yr) + + )} +
+
+ + Included monthly value: {formatUsdAmount(plan.monthlyBudgetUsd)} + + + 7-day cycle: {formatUsdAmount(plan.weeklyBudgetUsd)} + + + 5-hour cap: {formatUsdAmount(plan.fiveHourCapUsd)} + + + Discount: {plan.discountPercent}% + + + Storage: {formatStorageLimit(plan.storageLimitBytes)} + +
+
+ + {/* Action button */} + {isUpgrade && ( + + )} +
+
+ ); + })} +
+ + {/* Payment confirmed banner */} + {paymentConfirmed && ( +
+
+ + + +

+ Payment confirmed! Your plan has been updated. +

+
+
+ )} + + {/* Purchasing overlay message */} + {isPurchasing && ( +
+
+ + + + +

+ Waiting for payment confirmation... Complete checkout in the browser window that opened. +

+
+
+ )} + + {/* Pay with crypto toggle */} +
+
+

Pay with Crypto

+

+ You can choose to pay annually using crypto +

+
+ +
+ +); + +export default SubscriptionPlans; diff --git a/app/src/services/api/creditsApi.ts b/app/src/services/api/creditsApi.ts index 31e3f72f4..78ed8856b 100644 --- a/app/src/services/api/creditsApi.ts +++ b/app/src/services/api/creditsApi.ts @@ -110,6 +110,21 @@ export interface UpdateCardPayload { billingDetails?: CardBillingDetails; } +// ── Coupon types ──────────────────────────────────────────────────────────── + +export interface CouponRedeemResult { + success: boolean; + data: { code: string; amountUsd: number }; +} + +export interface RedeemedCoupon { + code: string; + amountUsd: number; + redeemedAt: string; + activationType: string; + fulfilled: boolean; +} + /** * Credits API endpoints */ @@ -207,4 +222,22 @@ export const creditsApi = { deleteCard: async (paymentMethodId: string): Promise => { return await callCoreCommand('openhuman.billing_delete_card', { paymentMethodId }); }, + + // ── Coupons ────────────────────────────────────────────────────────────── + + /** + * Redeem a coupon code to add credits. + * POST /coupons/redeem + */ + redeemCoupon: async (code: string): Promise => { + return await callCoreCommand('openhuman.billing_redeem_coupon', { code }); + }, + + /** + * List coupons redeemed by the current user. + * GET /coupons/me + */ + getUserCoupons: async (): Promise => { + return await callCoreCommand('openhuman.billing_get_coupons'); + }, }; diff --git a/src/openhuman/billing/ops.rs b/src/openhuman/billing/ops.rs index a7441e028..86fcc7573 100644 --- a/src/openhuman/billing/ops.rs +++ b/src/openhuman/billing/ops.rs @@ -285,6 +285,34 @@ pub async fn create_coinbase_charge( )) } +// ── Coupon operations ────────────────────────────────────────────────────── + +#[derive(Debug, Serialize)] +struct RedeemCouponBody<'a> { + code: &'a str, +} + +/// Redeem a coupon code to add credits to the user's account. +/// Maps to `POST /coupons/redeem`. +pub async fn redeem_coupon(config: &Config, code: &str) -> Result, String> { + let code = code.trim(); + if code.is_empty() { + return Err("code is required".to_string()); + } + + let body = json!(RedeemCouponBody { code }); + let data = get_authed_value(config, Method::POST, "/coupons/redeem", Some(body)).await?; + + Ok(RpcOutcome::single_log(data, "coupon redeemed")) +} + +/// List coupons redeemed by the current user. +/// Maps to `GET /coupons/me`. +pub async fn get_user_coupons(config: &Config) -> Result, String> { + let data = get_authed_value(config, Method::GET, "/coupons/me", None).await?; + Ok(RpcOutcome::single_log(data, "user coupons fetched")) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/openhuman/billing/schemas.rs b/src/openhuman/billing/schemas.rs index e738bf0d6..cf726b3cf 100644 --- a/src/openhuman/billing/schemas.rs +++ b/src/openhuman/billing/schemas.rs @@ -57,6 +57,12 @@ struct UpdateCardParams { payload: Value, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RedeemCouponParams { + code: String, +} + pub fn all_billing_controller_schemas() -> Vec { vec![ billing_schemas("billing_get_current_plan"), @@ -72,6 +78,8 @@ pub fn all_billing_controller_schemas() -> Vec { billing_schemas("billing_create_setup_intent"), billing_schemas("billing_update_card"), billing_schemas("billing_delete_card"), + billing_schemas("billing_redeem_coupon"), + billing_schemas("billing_get_coupons"), ] } @@ -129,6 +137,14 @@ pub fn all_billing_registered_controllers() -> Vec { schema: billing_schemas("billing_delete_card"), handler: handle_billing_delete_card, }, + RegisteredController { + schema: billing_schemas("billing_redeem_coupon"), + handler: handle_billing_redeem_coupon, + }, + RegisteredController { + schema: billing_schemas("billing_get_coupons"), + handler: handle_billing_get_coupons, + }, ] } @@ -327,6 +343,26 @@ pub fn billing_schemas(function: &str) -> ControllerSchema { )], outputs: vec![json_output("cards", "Updated saved cards payload.")], }, + "billing_redeem_coupon" => ControllerSchema { + namespace: "billing", + function: "redeem_coupon", + description: "Redeem a coupon code to add credits to the account.", + inputs: vec![required_string("code", "Coupon code to redeem.")], + outputs: vec![json_output( + "result", + "Coupon redemption result from /coupons/redeem.", + )], + }, + "billing_get_coupons" => ControllerSchema { + namespace: "billing", + function: "get_coupons", + description: "List coupons redeemed by the current user.", + inputs: vec![], + outputs: vec![json_output( + "coupons", + "User's redeemed coupons from /coupons/me.", + )], + }, _ => ControllerSchema { namespace: "billing", function: "unknown", @@ -467,6 +503,21 @@ fn handle_billing_delete_card(params: Map) -> ControllerFuture { }) } +fn handle_billing_redeem_coupon(params: Map) -> ControllerFuture { + Box::pin(async move { + let config = config_rpc::load_config_with_timeout().await?; + let payload = deserialize_params::(params)?; + to_json(crate::openhuman::billing::redeem_coupon(&config, payload.code.trim()).await?) + }) +} + +fn handle_billing_get_coupons(_params: Map) -> ControllerFuture { + Box::pin(async move { + let config = config_rpc::load_config_with_timeout().await?; + to_json(crate::openhuman::billing::get_user_coupons(&config).await?) + }) +} + fn to_json(outcome: RpcOutcome) -> Result { outcome.into_cli_compatible_json() }