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>
+3
View File
@@ -2035,6 +2035,7 @@
"Inspect requests, errors, and billing details": "Inspect requests, errors, and billing details",
"Inspect user prompts": "Inspect user prompts",
"Instance": "Instance",
"Insufficient balance": "Insufficient balance",
"Integrations": "Integrations",
"Inter-group overrides": "Inter-group overrides",
"Inter-group ratio overrides": "Inter-group ratio overrides",
@@ -2852,6 +2853,7 @@
"Path:": "Path:",
"Pay": "Pay",
"Pay-as-you-go with real-time usage monitoring": "Pay-as-you-go with real-time usage monitoring",
"Pay with Balance": "Pay with Balance",
"Payment": "Payment",
"Payment aggregator mode — onboard with your own registered company (offshore entity). Built for Enterprise.": "Payment aggregator mode — onboard with your own registered company (offshore entity). Built for Enterprise.",
"Payment Channel": "Payment Channel",
@@ -3763,6 +3765,7 @@
"Subscription First": "Subscription First",
"Subscription Management": "Subscription Management",
"Subscription Only": "Subscription Only",
"Subscription purchased successfully": "Subscription purchased successfully",
"Subscription plan creation and changes are locked until the administrator confirms compliance terms in Payment Gateway settings.": "Subscription plan creation and changes are locked until the administrator confirms compliance terms in Payment Gateway settings.",
"Subscription Plans": "Subscription Plans",
"Subscription plans do NOT use the bound Product — each plan has its own dedicated Pancake product, set in the Subscriptions admin (or auto-minted via the \"+ Create\" button there).": "Subscription plans do NOT use the bound Product — each plan has its own dedicated Pancake product, set in the Subscriptions admin (or auto-minted via the \"+ Create\" button there).",
+3
View File
@@ -2035,6 +2035,7 @@
"Inspect requests, errors, and billing details": "Inspecter les requêtes, les erreurs et les détails de facturation",
"Inspect user prompts": "Inspecter les invites utilisateur",
"Instance": "Instance",
"Insufficient balance": "Solde insuffisant",
"Integrations": "Intégrations",
"Inter-group overrides": "Dérogations inter-groupes",
"Inter-group ratio overrides": "Dérogations de ratio inter-groupes",
@@ -2852,6 +2853,7 @@
"Path:": "Chemin :",
"Pay": "Pay",
"Pay-as-you-go with real-time usage monitoring": "Paiement à l'usage avec suivi de la consommation en temps réel",
"Pay with Balance": "Payer avec le solde",
"Payment": "Paiement",
"Payment aggregator mode — onboard with your own registered company (offshore entity). Built for Enterprise.": "Mode agrégateur de paiement — embarquez avec votre propre société enregistrée (entité offshore). Conçu pour les entreprises.",
"Payment Channel": "Canal de paiement",
@@ -3763,6 +3765,7 @@
"Subscription First": "Abonnement en priorité",
"Subscription Management": "Gestion des abonnements",
"Subscription Only": "Abonnement uniquement",
"Subscription purchased successfully": "Abonnement acheté avec succès",
"Subscription plan creation and changes are locked until the administrator confirms compliance terms in Payment Gateway settings.": "La création et la modification des forfaits dabonnement sont verrouillées jusqu’à ce que ladministrateur confirme les conditions de conformité dans les paramètres de la passerelle de paiement.",
"Subscription Plans": "Plans d'abonnement",
"Subscription plans do NOT use the bound Product — each plan has its own dedicated Pancake product, set in the Subscriptions admin (or auto-minted via the \"+ Create\" button there).": "Les forfaits dabonnement nutilisent PAS le produit associé : chaque forfait dispose de son propre produit Pancake dédié, défini dans ladministration des abonnements (ou créé automatiquement via le bouton « + Créer »).",
+3
View File
@@ -2035,6 +2035,7 @@
"Inspect requests, errors, and billing details": "リクエスト、エラー、請求詳細を確認",
"Inspect user prompts": "ユーザープロンプトの検査",
"Instance": "インスタンス",
"Insufficient balance": "残高が不足しています",
"Integrations": "統合",
"Inter-group overrides": "グループ間上書き",
"Inter-group ratio overrides": "グループ間比率上書き",
@@ -2852,6 +2853,7 @@
"Path:": "パス:",
"Pay": "Pay",
"Pay-as-you-go with real-time usage monitoring": "リアルタイム使用量監視付き従量課金制",
"Pay with Balance": "残高で支払う",
"Payment": "支払い",
"Payment aggregator mode — onboard with your own registered company (offshore entity). Built for Enterprise.": "決済アグリゲーターモード — 自社の登録済み法人(オフショア法人)でオンボーディングします。エンタープライズ向けです。",
"Payment Channel": "決済チャネル",
@@ -3763,6 +3765,7 @@
"Subscription First": "サブスクリプション優先",
"Subscription Management": "サブスクリプション管理",
"Subscription Only": "サブスクリプションのみ",
"Subscription purchased successfully": "サブスクリプションを購入しました",
"Subscription plan creation and changes are locked until the administrator confirms compliance terms in Payment Gateway settings.": "管理者が支払いゲートウェイ設定でコンプライアンス条件を確認するまで、サブスクリプションプランの作成と変更はロックされます。",
"Subscription Plans": "サブスクリプションプラン",
"Subscription plans do NOT use the bound Product — each plan has its own dedicated Pancake product, set in the Subscriptions admin (or auto-minted via the \"+ Create\" button there).": "サブスクリプションプランは紐付け済み商品を使用しません。各プランには専用の Pancake 商品があり、サブスクリプション管理画面で設定します(または「+ 作成」ボタンで自動作成します)。",
+3
View File
@@ -2035,6 +2035,7 @@
"Inspect requests, errors, and billing details": "Проверяйте запросы, ошибки и детали оплаты",
"Inspect user prompts": "Просмотр запросов пользователя",
"Instance": "Экземпляр",
"Insufficient balance": "Недостаточно средств",
"Integrations": "Интеграции",
"Inter-group overrides": "Переопределения между группами",
"Inter-group ratio overrides": "Переопределения соотношений между группами",
@@ -2852,6 +2853,7 @@
"Path:": "Путь:",
"Pay": "Pay",
"Pay-as-you-go with real-time usage monitoring": "Оплата по мере использования с мониторингом в реальном времени",
"Pay with Balance": "Оплатить балансом",
"Payment": "Платеж",
"Payment aggregator mode — onboard with your own registered company (offshore entity). Built for Enterprise.": "Режим платежного агрегатора — подключение через вашу зарегистрированную компанию (офшорное юрлицо). Создано для Enterprise.",
"Payment Channel": "Платёжный канал",
@@ -3763,6 +3765,7 @@
"Subscription First": "Подписка в приоритете",
"Subscription Management": "Управление подписками",
"Subscription Only": "Только подписка",
"Subscription purchased successfully": "Подписка успешно приобретена",
"Subscription plan creation and changes are locked until the administrator confirms compliance terms in Payment Gateway settings.": "Создание и изменение планов подписки заблокированы, пока администратор не подтвердит условия соответствия в настройках платежного шлюза.",
"Subscription Plans": "Планы подписки",
"Subscription plans do NOT use the bound Product — each plan has its own dedicated Pancake product, set in the Subscriptions admin (or auto-minted via the \"+ Create\" button there).": "Планы подписки НЕ используют привязанный продукт — у каждого плана есть собственный продукт Pancake, задаваемый в администрировании подписок (или автоматически создаваемый кнопкой «+ Создать»).",
+3
View File
@@ -2035,6 +2035,7 @@
"Inspect requests, errors, and billing details": "Kiểm tra yêu cầu, lỗi và chi tiết thanh toán",
"Inspect user prompts": "Kiểm tra lời nhắc của người dùng",
"Instance": "Phiên bản",
"Insufficient balance": "Số dư không đủ",
"Integrations": "Tích hợp",
"Inter-group overrides": "Ghi đè liên nhóm",
"Inter-group ratio overrides": "Tỷ lệ liên nhóm ghi đè",
@@ -2852,6 +2853,7 @@
"Path:": "Đường dẫn:",
"Pay": "Pay",
"Pay-as-you-go with real-time usage monitoring": "Thanh toán theo mức sử dụng với theo dõi mức sử dụng theo thời gian thực",
"Pay with Balance": "Thanh toán bằng số dư",
"Payment": "Thanh toán",
"Payment aggregator mode — onboard with your own registered company (offshore entity). Built for Enterprise.": "Chế độ tổng hợp thanh toán — đăng ký bằng công ty đã đăng ký của bạn (pháp nhân offshore). Dành cho doanh nghiệp.",
"Payment Channel": "Kênh thanh toán",
@@ -3763,6 +3765,7 @@
"Subscription First": "Ưu tiên đăng ký",
"Subscription Management": "Quản lý đăng ký",
"Subscription Only": "Chỉ đăng ký",
"Subscription purchased successfully": "Đã mua gói đăng ký thành công",
"Subscription plan creation and changes are locked until the administrator confirms compliance terms in Payment Gateway settings.": "Việc tạo và thay đổi gói đăng ký bị khóa cho đến khi quản trị viên xác nhận điều khoản tuân thủ trong cài đặt Cổng thanh toán.",
"Subscription Plans": "Gói đăng ký",
"Subscription plans do NOT use the bound Product — each plan has its own dedicated Pancake product, set in the Subscriptions admin (or auto-minted via the \"+ Create\" button there).": "Gói đăng ký KHÔNG dùng Sản phẩm đã liên kết — mỗi gói có một sản phẩm Pancake riêng, được đặt trong quản trị Đăng ký (hoặc tự động tạo bằng nút \"+ Create\" tại đó).",
+3
View File
@@ -2035,6 +2035,7 @@
"Inspect requests, errors, and billing details": "查看请求、错误和计费详情",
"Inspect user prompts": "检查用户提示",
"Instance": "实例",
"Insufficient balance": "余额不足",
"Integrations": "集成",
"Inter-group overrides": "分组间覆盖",
"Inter-group ratio overrides": "分组间比例覆盖",
@@ -2852,6 +2853,7 @@
"Path:": "路径:",
"Pay": "支付",
"Pay-as-you-go with real-time usage monitoring": "按量付费,实时监控使用情况",
"Pay with Balance": "使用余额支付",
"Payment": "支付",
"Payment aggregator mode — onboard with your own registered company (offshore entity). Built for Enterprise.": "支付聚合模式:使用你自己的注册公司(离岸实体)入驻。面向企业场景构建。",
"Payment Channel": "支付渠道",
@@ -3763,6 +3765,7 @@
"Subscription First": "优先订阅",
"Subscription Management": "订阅管理",
"Subscription Only": "仅用订阅",
"Subscription purchased successfully": "订阅购买成功",
"Subscription plan creation and changes are locked until the administrator confirms compliance terms in Payment Gateway settings.": "管理员在支付网关设置中确认合规条款之前,订阅套餐的创建和修改会被锁定。",
"Subscription Plans": "订阅套餐",
"Subscription plans do NOT use the bound Product — each plan has its own dedicated Pancake product, set in the Subscriptions admin (or auto-minted via the \"+ Create\" button there).": "订阅套餐不会使用已绑定的产品。每个套餐都有独立的 Pancake 产品,可在订阅管理中设置,或通过其中的“+ 创建”按钮自动生成。",