feat(subscription): support balance purchases

Refs #3071.
This commit is contained in:
CaIon
2026-05-26 12:03:02 +08:00
parent 1011934987
commit 6b6c9904ac
14 changed files with 222 additions and 9 deletions
+7
View File
@@ -129,6 +129,13 @@ export async function paySubscriptionWaffoPancake(
return res.data
}
export async function paySubscriptionBalance(
data: SubscriptionPayRequest
): Promise<SubscriptionPayResponse> {
const res = await api.post('/api/subscription/balance/pay', data)
return res.data
}
// Mints a Pancake OnetimeProduct (see controller for the OnetimeProduct vs
// SubscriptionProduct rationale) using persisted creds + StoreID.
export async function createWaffoPancakeSubscriptionProduct(data: {
@@ -20,7 +20,9 @@ import { useState, useEffect } from 'react'
import { Crown, CalendarClock, Package } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { DEFAULT_CURRENCY_CONFIG } from '@/stores/system-config-store'
import { formatQuota } from '@/lib/format'
import { useSystemConfig } from '@/hooks/use-system-config'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import {
@@ -44,6 +46,7 @@ import {
paySubscriptionCreem,
paySubscriptionEpay,
paySubscriptionWaffoPancake,
paySubscriptionBalance,
} from '../../api'
import { formatDuration, formatResetPeriod } from '../../lib'
import type { PlanRecord } from '../../types'
@@ -64,10 +67,13 @@ interface Props {
epayMethods?: PaymentMethod[]
purchaseLimit?: number
purchaseCount?: number
userQuota?: number
onPurchaseSuccess?: () => void | Promise<void>
}
export function SubscriptionPurchaseDialog(props: Props) {
const { t } = useTranslation()
const { currency } = useSystemConfig()
const [paying, setPaying] = useState(false)
const [selectedEpayMethod, setSelectedEpayMethod] = useState('')
@@ -96,6 +102,16 @@ export function SubscriptionPurchaseDialog(props: Props) {
t('Select payment method')
const totalAmount = Number(plan.total_amount || 0)
const price = Number(plan.price_amount || 0).toFixed(2)
const quotaPerUnit =
currency?.quotaPerUnit && currency.quotaPerUnit > 0
? currency.quotaPerUnit
: DEFAULT_CURRENCY_CONFIG.quotaPerUnit
const balanceCost = Math.max(
0,
Math.ceil(Number(plan.price_amount || 0) * quotaPerUnit)
)
const userQuota = Math.max(0, Number(props.userQuota || 0))
const insufficientBalance = userQuota < balanceCost
const limitReached =
(props.purchaseLimit || 0) > 0 &&
(props.purchaseCount || 0) >= (props.purchaseLimit || 0)
@@ -215,6 +231,28 @@ export function SubscriptionPurchaseDialog(props: Props) {
}
}
const handlePayBalance = async () => {
setPaying(true)
try {
const res = await paySubscriptionBalance({ plan_id: plan.id })
if (res.success) {
toast.success(t('Subscription purchased successfully'))
void props.onPurchaseSuccess?.()
props.onOpenChange(false)
} else {
toast.error(
res.message && res.message !== 'success'
? res.message
: t('Payment request failed')
)
}
} catch {
toast.error(t('Payment request failed'))
} finally {
setPaying(false)
}
}
return (
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
<DialogContent className='max-sm:w-[calc(100vw-1.5rem)] sm:max-w-md'>
@@ -285,7 +323,30 @@ export function SubscriptionPurchaseDialog(props: Props) {
</Alert>
)}
{hasAnyPayment ? (
<div className='flex flex-col gap-2 rounded-md border p-3'>
<div className='flex items-center justify-between gap-2 text-xs'>
<span className='text-muted-foreground'>{t('Required')}</span>
<span>{formatQuota(balanceCost)}</span>
</div>
<div className='flex items-center justify-between gap-2 text-xs'>
<span className='text-muted-foreground'>{t('Available')}</span>
<span>{formatQuota(userQuota)}</span>
</div>
{insufficientBalance && (
<Alert variant='destructive'>
<AlertDescription>{t('Insufficient balance')}</AlertDescription>
</Alert>
)}
<Button
variant='outline'
onClick={handlePayBalance}
disabled={paying || limitReached || insufficientBalance}
>
{t('Pay with Balance')}
</Button>
</div>
{hasAnyPayment && (
<div className='space-y-3'>
<p className='text-muted-foreground text-xs'>
{t('Select payment method')}
@@ -361,14 +422,6 @@ export function SubscriptionPurchaseDialog(props: Props) {
</div>
)}
</div>
) : (
<Alert>
<AlertDescription>
{t(
'Online payment is not enabled. Please contact the administrator.'
)}
</AlertDescription>
</Alert>
)}
</div>
</DialogContent>
@@ -62,6 +62,8 @@ import type { PaymentMethod, TopupInfo } from '../types'
interface SubscriptionPlansCardProps {
topupInfo: TopupInfo | null
onAvailabilityChange?: (available: boolean) => void
userQuota?: number
onPurchaseSuccess?: () => void | Promise<void>
}
function getEpayMethods(payMethods: PaymentMethod[] = []): PaymentMethod[] {
@@ -91,6 +93,8 @@ function getBillingPreferenceLabel(
export function SubscriptionPlansCard({
topupInfo,
onAvailabilityChange,
userQuota,
onPurchaseSuccess,
}: SubscriptionPlansCardProps) {
const { t } = useTranslation()
@@ -633,6 +637,8 @@ export function SubscriptionPlansCard({
enableWaffoPancake={enableWaffoPancake}
enableOnlineTopUp={enableOnlineTopUp}
epayMethods={epayMethods}
userQuota={userQuota}
onPurchaseSuccess={onPurchaseSuccess}
purchaseLimit={
selectedPlan?.plan?.max_purchase_per_user
? Number(selectedPlan.plan.max_purchase_per_user)
+2
View File
@@ -309,6 +309,8 @@ export function Wallet(props: WalletProps) {
<SubscriptionPlansCard
topupInfo={topupInfo}
onAvailabilityChange={handleSubscriptionAvailabilityChange}
userQuota={user?.quota}
onPurchaseSuccess={fetchUser}
/>
</div>