diff --git a/components/Funding/PaymentStep.tsx b/components/Funding/PaymentStep.tsx index c575bf245..c9f7261e6 100644 --- a/components/Funding/PaymentStep.tsx +++ b/components/Funding/PaymentStep.tsx @@ -15,6 +15,7 @@ import type { EndaomentFund } from '@/services/endaoment.service'; import { usePaymentCalculations, getDefaultPaymentMethod, + type AllocateFromFundingPoolOption, type PaymentMethodType, type WalletAvailability, } from './lib'; @@ -39,6 +40,10 @@ interface PaymentStepProps { rscBalance: number; /** User's funding credits balance (excludes promotional RSC) */ fundingCreditsBalance?: number; + /** + * When set, offers allocate-from-RFP-pool. + */ + allocateFromPool?: AllocateFromFundingPoolOption | null; /** Fundraise or funding pool target for Apple Pay / Google Pay */ paymentTarget: PaymentIntentTarget; /** Wallet payment method availability from Stripe (resolved at modal level) */ @@ -71,6 +76,7 @@ export function PaymentStep({ amountDisplay, rscBalance, fundingCreditsBalance = 0, + allocateFromPool = null, paymentTarget, walletAvailability, hasNonprofit = false, @@ -81,6 +87,8 @@ export function PaymentStep({ onEndaomentPaymentConfirm, onStripeReady, }: PaymentStepProps) { + const fundingPoolHoldingRsc = allocateFromPool?.fundingPool.amountHolding.rsc ?? null; + const defaultPaymentMethod = useMemo( () => getDefaultPaymentMethod( @@ -88,32 +96,54 @@ export function PaymentStep({ fundingCreditsBalance, amountInRsc, PLATFORM_FEE_PERCENTAGE_RSC, - walletAvailability + walletAvailability, + fundingPoolHoldingRsc ), - [rscBalance, fundingCreditsBalance, amountInRsc, walletAvailability] + [rscBalance, fundingCreditsBalance, amountInRsc, walletAvailability, fundingPoolHoldingRsc] ); const [selectedMethod, setSelectedMethod] = useState( defaultPaymentMethod ); - // When wallet availability resolves and no method is selected yet, apply the default + // When wallet availability / pool option resolves and no method is selected yet, apply the default useEffect(() => { if (selectedMethod === null && defaultPaymentMethod !== null) { setSelectedMethod(defaultPaymentMethod); } }, [defaultPaymentMethod, selectedMethod]); + + useEffect(() => { + if ( + allocateFromPool && + fundingPoolHoldingRsc != null && + fundingPoolHoldingRsc >= amountInRsc && + selectedMethod !== 'funding_pool' + ) { + setSelectedMethod('funding_pool'); + } + }, [allocateFromPool?.fundingPool.id, fundingPoolHoldingRsc]); + const [isCreditCardComplete, setIsCreditCardComplete] = useState(false); const [selectedEndaomentFund, setSelectedEndaomentFund] = useState(null); // Balance check uses the balance that matches the selected method - // (available + promotional RSC for 'rsc', funding credits otherwise). + // (pool holding for funding_pool, funding credits, or available + promotional RSC). const balanceForSelectedMethod = - selectedMethod === 'funding_credits' ? fundingCreditsBalance : rscBalance; + selectedMethod === 'funding_pool' + ? (fundingPoolHoldingRsc ?? 0) + : selectedMethod === 'funding_credits' + ? fundingCreditsBalance + : rscBalance; const { insufficientBalance } = usePaymentCalculations({ amountInRsc, rscBalance: balanceForSelectedMethod, - paymentMethod: selectedMethod === 'funding_credits' ? 'funding_credits' : 'rsc', + paymentMethod: + selectedMethod === 'funding_credits' + ? 'funding_credits' + : selectedMethod === 'funding_pool' + ? 'funding_pool' + : 'rsc', }); // Calculate fees in USD - fees are ADDED on top of user's input @@ -122,10 +152,12 @@ export function PaymentStep({ selectedMethod && selectedMethod in PAYMENT_FEES ? PAYMENT_FEES[selectedMethod as keyof typeof PAYMENT_FEES] : PLATFORM_FEE_PERCENTAGE_RSC; - const platformFeeUsd = amountInUsd * (currentFeePercentage / 100); + const isFundingPoolMethod = selectedMethod === 'funding_pool'; + const platformFeeUsd = isFundingPoolMethod ? 0 : amountInUsd * (currentFeePercentage / 100); // Payment processing fee only for non-RSC methods - const hasProcessingFee = selectedMethod && METHODS_WITH_PROCESSING_FEE.includes(selectedMethod); + const hasProcessingFee = + selectedMethod && !isFundingPoolMethod && METHODS_WITH_PROCESSING_FEE.includes(selectedMethod); const processingFeeUsd = hasProcessingFee ? amountInUsd * (PAYMENT_PROCESSING_FEE.percentage / 100) + PAYMENT_PROCESSING_FEE.fixedCents / 100 @@ -137,7 +169,10 @@ export function PaymentStep({ `$${amount.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; const isRscInsufficientBalance = - (selectedMethod === 'rsc' || selectedMethod === 'funding_credits') && insufficientBalance; + (selectedMethod === 'rsc' || + selectedMethod === 'funding_credits' || + selectedMethod === 'funding_pool') && + insufficientBalance; // Check if selected Endaoment fund has insufficient balance const isEndaomentInsufficientBalance = Boolean( @@ -203,6 +238,7 @@ export function PaymentStep({ amountDisplay={amountDisplay} rscBalance={rscBalance} fundingCreditsBalance={fundingCreditsBalance} + allocateFromPool={allocateFromPool} onPreviewTransaction={handlePreviewTransaction} selectedPaymentMethod={selectedMethod} onPaymentMethodChange={handlePaymentMethodChange} @@ -221,57 +257,63 @@ export function PaymentStep({
{/* Funding contribution (amount going to fundraise) */}
- Funding contribution + + {isFundingPoolMethod ? 'Allocation from pool' : 'Funding contribution'} + {formatUsd(amountInUsd)}
- {/* Platform fee with tooltip */} -
-
- - Platform fee ({currentFeePercentage}%) - - - {/* Header with logo */} -
- - Platform Fee -
- - {/* Fee breakdown */} -
-
- ResearchHub Inc - - {currentFeePercentage - 2}% + {/* Platform fee with tooltip — omitted for funding-pool allocate (0%). */} + {!isFundingPoolMethod && ( +
+
+ + Platform fee ({currentFeePercentage}%) + + + {/* Header with logo */} +
+ + + Platform Fee
-
- ResearchHub Foundation - 2% + + {/* Fee breakdown */} +
+
+ ResearchHub Inc + + {currentFeePercentage - 2}% + +
+
+ ResearchHub Foundation + 2% +
-
- {/* Footer note */} -

- Supports open science infrastructure -

-
- } - width="w-64" - > - - - {(selectedMethod === 'rsc' || selectedMethod === 'funding_credits') && ( - - Lowest fee - - )} + {/* Footer note */} +

+ Supports open science infrastructure +

+
+ } + width="w-64" + > + + + {(selectedMethod === 'rsc' || selectedMethod === 'funding_credits') && ( + + Lowest fee + + )} +
+ {formatUsd(platformFeeUsd)}
- {formatUsd(platformFeeUsd)} -
+ )} {/* Payment processing fee - only for non-RSC methods */} {hasProcessingFee && ( @@ -288,7 +330,9 @@ export function PaymentStep({ Total Due
{formatUsd(totalDueUsd)} - {(selectedMethod === 'rsc' || selectedMethod === 'funding_credits') && ( + {(selectedMethod === 'rsc' || + selectedMethod === 'funding_credits' || + selectedMethod === 'funding_pool') && ( {(amountInRsc * (1 + currentFeePercentage / 100)).toLocaleString(undefined, { maximumFractionDigits: 0, @@ -300,8 +344,15 @@ export function PaymentStep({
- {/* Insufficient balance alert for RSC */} - {isRscInsufficientBalance && } + {/* Insufficient balance alert for RSC / pool */} + {isRscInsufficientBalance && + (isFundingPoolMethod ? ( + + Amount exceeds the funding pool balance available to allocate. + + ) : ( + + ))} {/* Insufficient balance alert for Endaoment */} {isEndaomentInsufficientBalance && } @@ -351,7 +402,11 @@ export function PaymentStep({ className="w-full h-12 text-base" onClick={handleConfirm} > - {isProcessing ? 'Processing...' : 'Confirm & Pay'} + {isProcessing + ? 'Processing...' + : isFundingPoolMethod + ? 'Confirm & Allocate' + : 'Confirm & Pay'} )}
diff --git a/components/Funding/PaymentWidget.tsx b/components/Funding/PaymentWidget.tsx index 6743f9678..59d50da30 100644 --- a/components/Funding/PaymentWidget.tsx +++ b/components/Funding/PaymentWidget.tsx @@ -1,7 +1,7 @@ 'use client'; import { useState, useEffect } from 'react'; -import { CreditCard, Plus, Minus, Check, Info } from 'lucide-react'; +import { CreditCard, Plus, Minus, Check, Coins } from 'lucide-react'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faApplePay, faGooglePay, faPaypal } from '@fortawesome/free-brands-svg-icons'; import { ResearchCoinIcon } from '@/components/ui/icons/ResearchCoinIcon'; @@ -12,6 +12,7 @@ import { usePaymentMethod, usePaymentCalculations, HIDDEN_PAYMENT_METHODS, + type AllocateFromFundingPoolOption, type PaymentMethodType, type WalletAvailability, } from './lib'; @@ -42,6 +43,10 @@ interface PaymentWidgetProps { rscBalance: number; /** User's funding credits balance (excludes promotional RSC) */ fundingCreditsBalance?: number; + /** + * When set, shows allocate-from-RFP-pool as a payment option for grant + */ + allocateFromPool?: AllocateFromFundingPoolOption | null; /** Called when user clicks "Preview Payment" (for payment methods with preview) */ onPreviewTransaction: (paymentMethod: Exclude) => void; /** Called when user clicks "Login to Endaoment" */ @@ -77,6 +82,7 @@ export function PaymentWidget({ amountDisplay, rscBalance, fundingCreditsBalance = 0, + allocateFromPool = null, onPreviewTransaction, onEndaomentLogin, isButtonDisabled = false, @@ -149,7 +155,16 @@ export function PaymentWidget({ ); }; + const fundingPoolHoldingRsc = allocateFromPool?.fundingPool.amountHolding.rsc ?? 0; + const paymentOptions: PaymentOption[] = [ + { + id: 'funding_pool', + title: 'Funding pool', + description: renderRscBalance(fundingPoolHoldingRsc), + icon: , + badge: 'No fee', + }, { id: 'funding_credits', title: 'Funding Credits', @@ -204,6 +219,7 @@ export function PaymentWidget({ ]; // Filter payment methods based on actual device capabilities from Stripe. + // - Hide Funding pool unless the user can allocate from the linked RFP pool // - Hide Endaoment if the fundraise has no associated non-profit // - Hide Apple Pay if not available on this device // - Hide Google Pay if not available OR if Apple Pay is available @@ -213,6 +229,9 @@ export function PaymentWidget({ // options that may not be available const visiblePaymentOptions = paymentOptions.filter((option) => { if (HIDDEN_PAYMENT_METHODS.includes(option.id)) return false; + if (option.id === 'funding_pool') { + return !!allocateFromPool && fundingPoolHoldingRsc > 0; + } if (option.id === 'funding_credits') { return fundingCreditsBalance > 0; } @@ -244,10 +263,17 @@ export function PaymentWidget({ icon?: React.ReactNode; } => { switch (selectedMethod) { + case 'funding_pool': + return { + text: 'Preview Payment', + onClick: () => onPreviewTransaction('funding_pool'), + disabled: isButtonDisabled || amountInRsc > fundingPoolHoldingRsc, + }; case 'rsc': + case 'funding_credits': return { text: 'Preview Payment', - onClick: () => onPreviewTransaction('rsc'), + onClick: () => onPreviewTransaction(selectedMethod), disabled: isButtonDisabled || isRscInsufficientBalance, }; case 'credit_card': diff --git a/components/Funding/ProposalWorkCard.tsx b/components/Funding/ProposalWorkCard.tsx index 7909c1d5f..ab0891213 100644 --- a/components/Funding/ProposalWorkCard.tsx +++ b/components/Funding/ProposalWorkCard.tsx @@ -23,11 +23,10 @@ import { useExchangeRate } from '@/contexts/ExchangeRateContext'; import { useFundraises } from '@/contexts/FundraiseContext'; import { useNavigation } from '@/contexts/NavigationContext'; import { useUser } from '@/contexts/UserContext'; -import { findGrantApplicationIdForPost } from '@/types/grant'; +import { findGrantApplicationIdForPost, type FundingPool } from '@/types/grant'; import { formatCurrency } from '@/utils/currency'; import type { FeedEntry } from '@/types/feed'; import type { Fundraise } from '@/types/funding'; -import type { FundingPool } from '@/types/grant'; interface ProposalWorkCardProps { entry: FeedEntry; diff --git a/components/Funding/index.ts b/components/Funding/index.ts index 3f14acba6..2a0754cf1 100644 --- a/components/Funding/index.ts +++ b/components/Funding/index.ts @@ -26,4 +26,6 @@ export { usePaymentCalculations, useWalletAvailability, type WalletAvailability, + useAllocateFromFundingPool, + type AllocateFromFundingPoolOption, } from './lib'; diff --git a/components/Funding/lib/constants.ts b/components/Funding/lib/constants.ts index 102250cd4..15f78cc46 100644 --- a/components/Funding/lib/constants.ts +++ b/components/Funding/lib/constants.ts @@ -39,10 +39,12 @@ export const RSC_PAYMENT_METHODS: PaymentMethodType[] = ['rsc', 'funding_credits /** * Per-method platform fee percentages. * RSC payments have a lower fee; credit card / wallet methods have a higher fee. + * Allocating from an RFP funding pool has no platform fee. */ export const PAYMENT_FEES = { rsc: PLATFORM_FEE_PERCENTAGE_RSC, funding_credits: PLATFORM_FEE_PERCENTAGE_RSC, + funding_pool: 0, credit_card: PLATFORM_FEE_PERCENTAGE_CARD, apple_pay: PLATFORM_FEE_PERCENTAGE_CARD, google_pay: PLATFORM_FEE_PERCENTAGE_CARD, @@ -56,6 +58,7 @@ export const PAYMENT_FEES = { export type PaymentMethodType = | 'rsc' | 'funding_credits' + | 'funding_pool' | 'credit_card' | 'endaoment' | 'apple_pay' @@ -74,6 +77,7 @@ export const HIDDEN_PAYMENT_METHODS: PaymentMethodType[] = ['paypal']; export const PAYMENT_METHOD_LABELS: Record = { rsc: 'ResearchCoin', funding_credits: 'Funding Credits', + funding_pool: 'Funding pool', credit_card: 'Credit Card', endaoment: 'Endaoment', apple_pay: 'Apple Pay', diff --git a/components/Funding/lib/getDefaultPaymentMethod.ts b/components/Funding/lib/getDefaultPaymentMethod.ts index 19d3a71b8..32df6083f 100644 --- a/components/Funding/lib/getDefaultPaymentMethod.ts +++ b/components/Funding/lib/getDefaultPaymentMethod.ts @@ -6,11 +6,12 @@ import { type WalletAvailability } from './useWalletAvailability'; * actual wallet availability (from Stripe's canMakePayment check). * * Priority: - * 1. Funding Credits — if the user's funding credits cover the contribution - * 2. RSC - if available + promotional RSC covers the contribution - * 3. Apple Pay - if available on this device - * 4. Google Pay - if available on this device - * 5. Credit Card - fallback + * 1. Funding pool — grant creators/mods allocating from RFP holdings (no fees) + * 2. Funding Credits — if the user's funding credits cover the contribution + * 3. RSC - if available + promotional RSC covers the contribution + * 4. Apple Pay - if available on this device + * 5. Google Pay - if available on this device + * 6. Credit Card - fallback * * Returns `null` when wallet availability is still being checked and neither * RSC-based method can cover the amount. @@ -20,8 +21,13 @@ export function getDefaultPaymentMethod( fundingCreditsBalance: number, amountInRsc: number, platformFeePercent: number, - walletAvailability: WalletAvailability + walletAvailability: WalletAvailability, + fundingPoolHoldingRsc?: number | null ): PaymentMethodType | null { + if (fundingPoolHoldingRsc != null && fundingPoolHoldingRsc >= amountInRsc) { + return 'funding_pool'; + } + const rscAmountWithFees = amountInRsc * (1 + platformFeePercent / 100); if (fundingCreditsBalance >= rscAmountWithFees) { diff --git a/components/Funding/lib/index.ts b/components/Funding/lib/index.ts index cae56a18d..d22541695 100644 --- a/components/Funding/lib/index.ts +++ b/components/Funding/lib/index.ts @@ -13,3 +13,7 @@ export { useWalletAvailability, type WalletAvailability } from './useWalletAvail // Utilities export { getDefaultPaymentMethod } from './getDefaultPaymentMethod'; +export { + useAllocateFromFundingPool, + type AllocateFromFundingPoolOption, +} from './useAllocateFromFundingPool'; diff --git a/components/Funding/lib/useAllocateFromFundingPool.ts b/components/Funding/lib/useAllocateFromFundingPool.ts new file mode 100644 index 000000000..bd264461c --- /dev/null +++ b/components/Funding/lib/useAllocateFromFundingPool.ts @@ -0,0 +1,56 @@ +'use client'; + +import { useMemo } from 'react'; +import type { FundingPool } from '@/types/grant'; +import type { Work } from '@/types/work'; +import type { ID } from '@/types/root'; +import type { User } from '@/types/user'; + +export interface AllocateFromFundingPoolOption { + fundingPool: FundingPool; + applicationId: ID; +} + +interface UseAllocateFromFundingPoolOptions { + enabled: boolean; + work?: Work | null; + user?: User | null; +} + +interface UseAllocateFromFundingPoolResult { + allocateFromPool: AllocateFromFundingPoolOption | null; +} + +/** + * Reads allocate-from-pool eligibility from the proposal's `linkedGrant` + * (`fundingPool`, `applicationId`, `createdByUserId` mapped from `grants[0]`). + */ +export function useAllocateFromFundingPool({ + enabled, + work, + user, +}: UseAllocateFromFundingPoolOptions): UseAllocateFromFundingPoolResult { + const allocateFromPool = useMemo((): AllocateFromFundingPoolOption | null => { + const linked = work?.linkedGrant; + const fundingPool = linked?.fundingPool; + const applicationId = linked?.applicationId; + + if (!enabled || !user?.id || !linked || !fundingPool || applicationId == null) { + return null; + } + + const isGrantCreator = + linked.createdByUserId != null && Number(user.id) === Number(linked.createdByUserId); + if (!isGrantCreator && !user.isModerator) { + return null; + } + + if (fundingPool.status !== 'OPEN' || (fundingPool.amountHolding.rsc ?? 0) <= 0) { + return null; + } + + return { fundingPool, applicationId }; + }, [enabled, work?.linkedGrant, user]); + + return { allocateFromPool }; +} diff --git a/components/Funding/lib/usePaymentCalculations.ts b/components/Funding/lib/usePaymentCalculations.ts index 9a601b5dd..db33ee8f1 100644 --- a/components/Funding/lib/usePaymentCalculations.ts +++ b/components/Funding/lib/usePaymentCalculations.ts @@ -69,11 +69,14 @@ export function usePaymentCalculations({ const totalAmountUsd = useMemo(() => rscToUsd(totalAmount), [rscToUsd, totalAmount]); - // Balance check (only relevant for RSC-based payments — caller passes the - // correct balance for the selected method). + // Balance check (RSC / credits / funding-pool holding — caller passes the + // balance that matches the selected method). const insufficientBalance = useMemo( () => - (paymentMethod === 'rsc' || paymentMethod === 'funding_credits') && rscBalance < totalAmount, + (paymentMethod === 'rsc' || + paymentMethod === 'funding_credits' || + paymentMethod === 'funding_pool') && + rscBalance < totalAmount, [paymentMethod, rscBalance, totalAmount] ); diff --git a/components/modals/ContributeToFundraiseModal.tsx b/components/modals/ContributeToFundraiseModal.tsx index 3413822fe..252610a7b 100644 --- a/components/modals/ContributeToFundraiseModal.tsx +++ b/components/modals/ContributeToFundraiseModal.tsx @@ -20,6 +20,7 @@ import { QuickAmountSelector, StripeProvider, useWalletAvailability, + useAllocateFromFundingPool, type PaymentMethodType, type StripePaymentContext, } from '@/components/Funding'; @@ -147,6 +148,13 @@ function ContributeToFundraiseModalInner(props: Readonly holdingRsc) { + setError('Amount exceeds the funding pool balance available to allocate.'); + AnalyticsService.logEvent(LogEvent.FUNDRAISE_CONTRIBUTION_PAYMENT_ERROR, { + ...analyticsTarget, + payment_method: paymentMethod, + error_type: 'validation', + error_message: 'Amount exceeds funding pool holding', + }); + setIsContributing(false); + return; + } + + updatedPool = await FundingPoolService.distribute(allocateFromPool.fundingPool.id, { + amount: amountInRsc, + applicationId: allocateFromPool.applicationId, + }); + toast.success('Allocated to proposal'); + } else if (paymentMethod === 'rsc' || paymentMethod === 'funding_credits') { // The backend draws from funding credits only when that payment method // is selected. Otherwise it draws from available and promotional RSC. if (isPoolMode && fundingPool) { @@ -596,6 +629,7 @@ function ContributeToFundraiseModalInner(props: Readonly