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
+23
View File
@@ -22,6 +22,10 @@ type BillingPreferenceRequest struct {
BillingPreference string `json:"billing_preference"`
}
type SubscriptionBalancePayRequest struct {
PlanId int `json:"plan_id"`
}
// ---- User APIs ----
func GetSubscriptionPlans(c *gin.Context) {
@@ -92,6 +96,25 @@ func UpdateSubscriptionPreference(c *gin.Context) {
common.ApiSuccess(c, gin.H{"billing_preference": pref})
}
func SubscriptionRequestBalancePay(c *gin.Context) {
if !requirePaymentCompliance(c) {
return
}
userId := c.GetInt("id")
var req SubscriptionBalancePayRequest
if err := c.ShouldBindJSON(&req); err != nil || req.PlanId <= 0 {
common.ApiErrorMsg(c, "参数错误")
return
}
if err := model.PurchaseSubscriptionWithBalance(userId, req.PlanId); err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, nil)
}
// ---- Admin APIs ----
func AdminListSubscriptionPlans(c *gin.Context) {
+101
View File
@@ -11,6 +11,7 @@ import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/pkg/cachex"
"github.com/samber/hot"
"github.com/shopspring/decimal"
"gorm.io/gorm"
)
@@ -665,6 +666,106 @@ func AdminBindSubscription(userId int, planId int, sourceNote string) (string, e
return "", nil
}
func calcSubscriptionBalanceQuota(priceAmount float64) (int, error) {
if priceAmount <= 0 {
return 0, nil
}
if common.QuotaPerUnit <= 0 {
return 0, errors.New("额度单位配置错误")
}
quota := decimal.NewFromFloat(priceAmount).
Mul(decimal.NewFromFloat(common.QuotaPerUnit)).
Ceil().
IntPart()
return int(quota), nil
}
// PurchaseSubscriptionWithBalance creates a subscription by deducting the user's wallet quota.
func PurchaseSubscriptionWithBalance(userId int, planId int) error {
if userId <= 0 || planId <= 0 {
return errors.New("invalid userId or planId")
}
var logPlanTitle string
var logMoney float64
var chargedQuota int
var upgradeGroup string
err := DB.Transaction(func(tx *gorm.DB) error {
plan, err := getSubscriptionPlanByIdTx(tx, planId)
if err != nil {
return err
}
if !plan.Enabled {
return errors.New("套餐未启用")
}
if plan.PriceAmount < 0 {
return errors.New("套餐价格不能为负数")
}
requiredQuota, err := calcSubscriptionBalanceQuota(plan.PriceAmount)
if err != nil {
return err
}
var user User
if err := tx.Set("gorm:query_option", "FOR UPDATE").Where("id = ?", userId).First(&user).Error; err != nil {
return err
}
if requiredQuota > 0 && user.Quota < requiredQuota {
return errors.New("余额不足")
}
if requiredQuota > 0 {
if err := tx.Model(&User{}).Where("id = ?", userId).
Update("quota", gorm.Expr("quota - ?", requiredQuota)).Error; err != nil {
return err
}
}
if _, err := CreateUserSubscriptionFromPlanTx(tx, userId, plan, PaymentMethodBalance); err != nil {
return err
}
now := common.GetTimestamp()
tradeNo := fmt.Sprintf("SUBBALUSR%dNO%s%d", userId, common.GetRandomString(6), time.Now().UnixNano())
order := &SubscriptionOrder{
UserId: userId,
PlanId: plan.Id,
Money: plan.PriceAmount,
TradeNo: tradeNo,
PaymentMethod: PaymentMethodBalance,
PaymentProvider: PaymentProviderBalance,
Status: common.TopUpStatusSuccess,
CreateTime: now,
CompleteTime: now,
ProviderPayload: fmt.Sprintf("charged_quota=%d", requiredQuota),
}
if err := tx.Create(order).Error; err != nil {
return err
}
logPlanTitle = plan.Title
logMoney = plan.PriceAmount
chargedQuota = requiredQuota
upgradeGroup = strings.TrimSpace(plan.UpgradeGroup)
return nil
})
if err != nil {
return err
}
if chargedQuota > 0 {
if err := cacheDecrUserQuota(userId, int64(chargedQuota)); err != nil {
common.SysLog("failed to decrease user quota cache after subscription balance purchase: " + err.Error())
}
}
if upgradeGroup != "" {
_ = UpdateUserGroupCache(userId, upgradeGroup)
}
msg := fmt.Sprintf("使用余额购买订阅成功,套餐: %s,支付金额: %.2f,扣除额度: %d", logPlanTitle, logMoney, chargedQuota)
RecordLog(userId, LogTypeTopup, msg)
return nil
}
// GetAllActiveUserSubscriptions returns all active subscriptions for a user.
func GetAllActiveUserSubscriptions(userId int) ([]SubscriptionSummary, error) {
if userId <= 0 {
+2
View File
@@ -29,6 +29,7 @@ const (
PaymentMethodCreem = "creem"
PaymentMethodWaffo = "waffo"
PaymentMethodWaffoPancake = "waffo_pancake"
PaymentMethodBalance = "balance"
)
const (
@@ -37,6 +38,7 @@ const (
PaymentProviderCreem = "creem"
PaymentProviderWaffo = "waffo"
PaymentProviderWaffoPancake = "waffo_pancake"
PaymentProviderBalance = "balance"
)
var (
+1
View File
@@ -153,6 +153,7 @@ func SetApiRouter(router *gin.Engine) {
subscriptionRoute.GET("/plans", controller.GetSubscriptionPlans)
subscriptionRoute.GET("/self", controller.GetSubscriptionSelf)
subscriptionRoute.PUT("/self/preference", controller.UpdateSubscriptionPreference)
subscriptionRoute.POST("/balance/pay", middleware.CriticalRateLimit(), controller.SubscriptionRequestBalancePay)
subscriptionRoute.POST("/epay/pay", middleware.CriticalRateLimit(), controller.SubscriptionRequestEpay)
subscriptionRoute.POST("/stripe/pay", middleware.CriticalRateLimit(), controller.SubscriptionRequestStripePay)
subscriptionRoute.POST("/creem/pay", middleware.CriticalRateLimit(), controller.SubscriptionRequestCreemPay)
+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 产品,可在订阅管理中设置,或通过其中的“+ 创建”按钮自动生成。",