Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
167 changes: 111 additions & 56 deletions components/Funding/PaymentStep.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import {
usePaymentCalculations,
getDefaultPaymentMethod,
type AllocateFromFundingPoolOption,
type PaymentMethodType,
type WalletAvailability,
} from './lib';
Expand All @@ -39,6 +40,10 @@
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) */
Expand Down Expand Up @@ -71,6 +76,7 @@
amountDisplay,
rscBalance,
fundingCreditsBalance = 0,
allocateFromPool = null,
paymentTarget,
walletAvailability,
hasNonprofit = false,
Expand All @@ -81,39 +87,63 @@
onEndaomentPaymentConfirm,
onStripeReady,
}: PaymentStepProps) {
const fundingPoolHoldingRsc = allocateFromPool?.fundingPool.amountHolding.rsc ?? null;

const defaultPaymentMethod = useMemo(
() =>
getDefaultPaymentMethod(
rscBalance,
fundingCreditsBalance,
amountInRsc,
PLATFORM_FEE_PERCENTAGE_RSC,
walletAvailability
walletAvailability,
fundingPoolHoldingRsc
),
[rscBalance, fundingCreditsBalance, amountInRsc, walletAvailability]
[rscBalance, fundingCreditsBalance, amountInRsc, walletAvailability, fundingPoolHoldingRsc]
);

const [selectedMethod, setSelectedMethod] = useState<PaymentMethodType | null>(
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<EndaomentFund | null>(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;

Check warning on line 137 in components/Funding/PaymentStep.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested ternary operation into an independent statement.

See more on https://sonarcloud.io/project/issues?id=ResearchHub_web&issues=AaCwh1xsNkS-bth8GPbU&open=AaCwh1xsNkS-bth8GPbU&pullRequest=1112
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',

Check warning on line 146 in components/Funding/PaymentStep.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested ternary operation into an independent statement.

See more on https://sonarcloud.io/project/issues?id=ResearchHub_web&issues=AaCwh1xsNkS-bth8GPbV&open=AaCwh1xsNkS-bth8GPbV&pullRequest=1112
});

// Calculate fees in USD - fees are ADDED on top of user's input
Expand All @@ -122,10 +152,12 @@
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
Expand All @@ -137,7 +169,10 @@
`$${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(
Expand Down Expand Up @@ -203,6 +238,7 @@
amountDisplay={amountDisplay}
rscBalance={rscBalance}
fundingCreditsBalance={fundingCreditsBalance}
allocateFromPool={allocateFromPool}
onPreviewTransaction={handlePreviewTransaction}
selectedPaymentMethod={selectedMethod}
onPaymentMethodChange={handlePaymentMethodChange}
Expand All @@ -221,57 +257,63 @@
<div className="space-y-1">
{/* Funding contribution (amount going to fundraise) */}
<div className="py-1.5 flex items-center justify-between">
<span className="text-sm text-gray-600">Funding contribution</span>
<span className="text-sm text-gray-600">
{isFundingPoolMethod ? 'Allocation from pool' : 'Funding contribution'}
</span>
<span className="text-sm text-gray-900">{formatUsd(amountInUsd)}</span>
</div>

{/* Platform fee with tooltip */}
<div className="py-1.5 flex items-center justify-between">
<div className="flex items-center gap-1.5">
<span className="text-sm text-gray-600">
Platform fee ({currentFeePercentage}%)
</span>
<Tooltip
content={
<div className="text-left space-y-3 py-1">
{/* Header with logo */}
<div className="flex items-center gap-2 pb-2 border-b border-gray-100">
<Logo noText size={32} />
<span className="text-base font-medium text-gray-800">Platform Fee</span>
</div>

{/* Fee breakdown */}
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-gray-600">ResearchHub Inc</span>
<span className="font-medium text-gray-800">
{currentFeePercentage - 2}%
{/* Platform fee with tooltip — omitted for funding-pool allocate (0%). */}
{!isFundingPoolMethod && (
<div className="py-1.5 flex items-center justify-between">
<div className="flex items-center gap-1.5">
<span className="text-sm text-gray-600">
Platform fee ({currentFeePercentage}%)
</span>
<Tooltip
content={
<div className="text-left space-y-3 py-1">
{/* Header with logo */}
<div className="flex items-center gap-2 pb-2 border-b border-gray-100">
<Logo noText size={32} />
<span className="text-base font-medium text-gray-800">
Platform Fee
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-gray-600">ResearchHub Foundation</span>
<span className="font-medium text-gray-800">2%</span>

{/* Fee breakdown */}
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-gray-600">ResearchHub Inc</span>
<span className="font-medium text-gray-800">
{currentFeePercentage - 2}%
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-gray-600">ResearchHub Foundation</span>
<span className="font-medium text-gray-800">2%</span>
</div>
</div>
</div>

{/* Footer note */}
<p className="text-xs text-gray-600 pt-1 border-t border-gray-100">
Supports open science infrastructure
</p>
</div>
}
width="w-64"
>
<Info className="h-4 w-4 text-gray-500 cursor-help" />
</Tooltip>
{(selectedMethod === 'rsc' || selectedMethod === 'funding_credits') && (
<span className="px-1.5 py-0.5 text-xs font-medium rounded-full bg-green-100 text-green-700">
Lowest fee
</span>
)}
{/* Footer note */}
<p className="text-xs text-gray-600 pt-1 border-t border-gray-100">
Supports open science infrastructure
</p>
</div>
}
width="w-64"
>
<Info className="h-4 w-4 text-gray-500 cursor-help" />
</Tooltip>
{(selectedMethod === 'rsc' || selectedMethod === 'funding_credits') && (
<span className="px-1.5 py-0.5 text-xs font-medium rounded-full bg-green-100 text-green-700">
Lowest fee
</span>
)}
</div>
<span className="text-sm text-gray-600">{formatUsd(platformFeeUsd)}</span>
</div>
<span className="text-sm text-gray-600">{formatUsd(platformFeeUsd)}</span>
</div>
)}

{/* Payment processing fee - only for non-RSC methods */}
{hasProcessingFee && (
Expand All @@ -288,7 +330,9 @@
<span className="text-base font-semibold text-gray-900">Total Due</span>
<div className="flex flex-col items-end">
<span className="text-lg font-bold text-gray-900">{formatUsd(totalDueUsd)}</span>
{(selectedMethod === 'rsc' || selectedMethod === 'funding_credits') && (
{(selectedMethod === 'rsc' ||
selectedMethod === 'funding_credits' ||
selectedMethod === 'funding_pool') && (
<span className="text-xs text-gray-500">
{(amountInRsc * (1 + currentFeePercentage / 100)).toLocaleString(undefined, {
maximumFractionDigits: 0,
Expand All @@ -300,8 +344,15 @@
</div>
</div>

{/* Insufficient balance alert for RSC */}
{isRscInsufficientBalance && <InsufficientBalanceAlert />}
{/* Insufficient balance alert for RSC / pool */}
{isRscInsufficientBalance &&
(isFundingPoolMethod ? (
<Alert variant="error">
Amount exceeds the funding pool balance available to allocate.
</Alert>
) : (
<InsufficientBalanceAlert />
))}

{/* Insufficient balance alert for Endaoment */}
{isEndaomentInsufficientBalance && <EndaomentInsufficientFundsAlert />}
Expand Down Expand Up @@ -351,7 +402,11 @@
className="w-full h-12 text-base"
onClick={handleConfirm}
>
{isProcessing ? 'Processing...' : 'Confirm & Pay'}
{isProcessing
? 'Processing...'
: isFundingPoolMethod
? 'Confirm & Allocate'
: 'Confirm & Pay'}

Check warning on line 409 in components/Funding/PaymentStep.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested ternary operation into an independent statement.

See more on https://sonarcloud.io/project/issues?id=ResearchHub_web&issues=AaCwh1xsNkS-bth8GPbW&open=AaCwh1xsNkS-bth8GPbW&pullRequest=1112
</Button>
)}
</div>
Expand Down
30 changes: 28 additions & 2 deletions components/Funding/PaymentWidget.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -12,6 +12,7 @@ import {
usePaymentMethod,
usePaymentCalculations,
HIDDEN_PAYMENT_METHODS,
type AllocateFromFundingPoolOption,
type PaymentMethodType,
type WalletAvailability,
} from './lib';
Expand Down Expand Up @@ -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<PaymentMethodType, 'endaoment' | 'other'>) => void;
/** Called when user clicks "Login to Endaoment" */
Expand Down Expand Up @@ -77,6 +82,7 @@ export function PaymentWidget({
amountDisplay,
rscBalance,
fundingCreditsBalance = 0,
allocateFromPool = null,
onPreviewTransaction,
onEndaomentLogin,
isButtonDisabled = false,
Expand Down Expand Up @@ -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: <Coins className="h-[18px] w-[18px] text-primary-600" />,
badge: 'No fee',
},
{
id: 'funding_credits',
title: 'Funding Credits',
Expand Down Expand Up @@ -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
Expand All @@ -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;
}
Expand Down Expand Up @@ -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':
Expand Down
3 changes: 1 addition & 2 deletions components/Funding/ProposalWorkCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions components/Funding/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,6 @@ export {
usePaymentCalculations,
useWalletAvailability,
type WalletAvailability,
useAllocateFromFundingPool,
type AllocateFromFundingPoolOption,
} from './lib';
Loading
Loading