Commit f6c26043 by CaIon

feat(subscription): add support for wallet overflow and downgrade group…

feat(subscription): add support for wallet overflow and downgrade group functionality in subscription plans
parent 4e8b5e9b
...@@ -168,6 +168,9 @@ func AdminCreateSubscriptionPlan(c *gin.Context) { ...@@ -168,6 +168,9 @@ func AdminCreateSubscriptionPlan(c *gin.Context) {
if req.Plan.AllowBalancePay == nil { if req.Plan.AllowBalancePay == nil {
req.Plan.AllowBalancePay = common.GetPointer(true) req.Plan.AllowBalancePay = common.GetPointer(true)
} }
if req.Plan.AllowWalletOverflow == nil {
req.Plan.AllowWalletOverflow = common.GetPointer(true)
}
if req.Plan.DurationUnit == "" { if req.Plan.DurationUnit == "" {
req.Plan.DurationUnit = model.SubscriptionDurationMonth req.Plan.DurationUnit = model.SubscriptionDurationMonth
} }
...@@ -189,6 +192,13 @@ func AdminCreateSubscriptionPlan(c *gin.Context) { ...@@ -189,6 +192,13 @@ func AdminCreateSubscriptionPlan(c *gin.Context) {
return return
} }
} }
req.Plan.DowngradeGroup = strings.TrimSpace(req.Plan.DowngradeGroup)
if req.Plan.DowngradeGroup != "" {
if _, ok := ratio_setting.GetGroupRatioCopy()[req.Plan.DowngradeGroup]; !ok {
common.ApiErrorMsg(c, "降级分组不存在")
return
}
}
req.Plan.QuotaResetPeriod = model.NormalizeResetPeriod(req.Plan.QuotaResetPeriod) req.Plan.QuotaResetPeriod = model.NormalizeResetPeriod(req.Plan.QuotaResetPeriod)
if req.Plan.QuotaResetPeriod == model.SubscriptionResetCustom && req.Plan.QuotaResetCustomSeconds <= 0 { if req.Plan.QuotaResetPeriod == model.SubscriptionResetCustom && req.Plan.QuotaResetCustomSeconds <= 0 {
common.ApiErrorMsg(c, "自定义重置周期需大于0秒") common.ApiErrorMsg(c, "自定义重置周期需大于0秒")
...@@ -256,6 +266,13 @@ func AdminUpdateSubscriptionPlan(c *gin.Context) { ...@@ -256,6 +266,13 @@ func AdminUpdateSubscriptionPlan(c *gin.Context) {
return return
} }
} }
req.Plan.DowngradeGroup = strings.TrimSpace(req.Plan.DowngradeGroup)
if req.Plan.DowngradeGroup != "" {
if _, ok := ratio_setting.GetGroupRatioCopy()[req.Plan.DowngradeGroup]; !ok {
common.ApiErrorMsg(c, "降级分组不存在")
return
}
}
req.Plan.QuotaResetPeriod = model.NormalizeResetPeriod(req.Plan.QuotaResetPeriod) req.Plan.QuotaResetPeriod = model.NormalizeResetPeriod(req.Plan.QuotaResetPeriod)
if req.Plan.QuotaResetPeriod == model.SubscriptionResetCustom && req.Plan.QuotaResetCustomSeconds <= 0 { if req.Plan.QuotaResetPeriod == model.SubscriptionResetCustom && req.Plan.QuotaResetCustomSeconds <= 0 {
common.ApiErrorMsg(c, "自定义重置周期需大于0秒") common.ApiErrorMsg(c, "自定义重置周期需大于0秒")
...@@ -280,6 +297,7 @@ func AdminUpdateSubscriptionPlan(c *gin.Context) { ...@@ -280,6 +297,7 @@ func AdminUpdateSubscriptionPlan(c *gin.Context) {
"max_purchase_per_user": req.Plan.MaxPurchasePerUser, "max_purchase_per_user": req.Plan.MaxPurchasePerUser,
"total_amount": req.Plan.TotalAmount, "total_amount": req.Plan.TotalAmount,
"upgrade_group": req.Plan.UpgradeGroup, "upgrade_group": req.Plan.UpgradeGroup,
"downgrade_group": req.Plan.DowngradeGroup,
"quota_reset_period": req.Plan.QuotaResetPeriod, "quota_reset_period": req.Plan.QuotaResetPeriod,
"quota_reset_custom_seconds": req.Plan.QuotaResetCustomSeconds, "quota_reset_custom_seconds": req.Plan.QuotaResetCustomSeconds,
"updated_at": common.GetTimestamp(), "updated_at": common.GetTimestamp(),
...@@ -287,6 +305,9 @@ func AdminUpdateSubscriptionPlan(c *gin.Context) { ...@@ -287,6 +305,9 @@ func AdminUpdateSubscriptionPlan(c *gin.Context) {
if req.Plan.AllowBalancePay != nil { if req.Plan.AllowBalancePay != nil {
updateMap["allow_balance_pay"] = *req.Plan.AllowBalancePay updateMap["allow_balance_pay"] = *req.Plan.AllowBalancePay
} }
if req.Plan.AllowWalletOverflow != nil {
updateMap["allow_wallet_overflow"] = *req.Plan.AllowWalletOverflow
}
if err := tx.Model(&model.SubscriptionPlan{}).Where("id = ?", id).Updates(updateMap).Error; err != nil { if err := tx.Model(&model.SubscriptionPlan{}).Where("id = ?", id).Updates(updateMap).Error; err != nil {
return err return err
} }
......
...@@ -398,11 +398,13 @@ func ensureSubscriptionPlanTableSQLite() error { ...@@ -398,11 +398,13 @@ func ensureSubscriptionPlanTableSQLite() error {
` + "`enabled`" + ` numeric DEFAULT 1, ` + "`enabled`" + ` numeric DEFAULT 1,
` + "`sort_order`" + ` integer DEFAULT 0, ` + "`sort_order`" + ` integer DEFAULT 0,
` + "`allow_balance_pay`" + ` numeric DEFAULT 1, ` + "`allow_balance_pay`" + ` numeric DEFAULT 1,
` + "`allow_wallet_overflow`" + ` numeric DEFAULT 1,
` + "`stripe_price_id`" + ` varchar(128) DEFAULT '', ` + "`stripe_price_id`" + ` varchar(128) DEFAULT '',
` + "`creem_product_id`" + ` varchar(128) DEFAULT '', ` + "`creem_product_id`" + ` varchar(128) DEFAULT '',
` + "`waffo_pancake_product_id`" + ` varchar(128) DEFAULT '', ` + "`waffo_pancake_product_id`" + ` varchar(128) DEFAULT '',
` + "`max_purchase_per_user`" + ` integer DEFAULT 0, ` + "`max_purchase_per_user`" + ` integer DEFAULT 0,
` + "`upgrade_group`" + ` varchar(64) DEFAULT '', ` + "`upgrade_group`" + ` varchar(64) DEFAULT '',
` + "`downgrade_group`" + ` varchar(64) DEFAULT '',
` + "`total_amount`" + ` bigint NOT NULL DEFAULT 0, ` + "`total_amount`" + ` bigint NOT NULL DEFAULT 0,
` + "`quota_reset_period`" + ` varchar(16) DEFAULT 'never', ` + "`quota_reset_period`" + ` varchar(16) DEFAULT 'never',
` + "`quota_reset_custom_seconds`" + ` bigint DEFAULT 0, ` + "`quota_reset_custom_seconds`" + ` bigint DEFAULT 0,
...@@ -433,11 +435,13 @@ PRIMARY KEY (` + "`id`" + `) ...@@ -433,11 +435,13 @@ PRIMARY KEY (` + "`id`" + `)
{Name: "enabled", DDL: "`enabled` numeric DEFAULT 1"}, {Name: "enabled", DDL: "`enabled` numeric DEFAULT 1"},
{Name: "sort_order", DDL: "`sort_order` integer DEFAULT 0"}, {Name: "sort_order", DDL: "`sort_order` integer DEFAULT 0"},
{Name: "allow_balance_pay", DDL: "`allow_balance_pay` numeric DEFAULT 1"}, {Name: "allow_balance_pay", DDL: "`allow_balance_pay` numeric DEFAULT 1"},
{Name: "allow_wallet_overflow", DDL: "`allow_wallet_overflow` numeric DEFAULT 1"},
{Name: "stripe_price_id", DDL: "`stripe_price_id` varchar(128) DEFAULT ''"}, {Name: "stripe_price_id", DDL: "`stripe_price_id` varchar(128) DEFAULT ''"},
{Name: "creem_product_id", DDL: "`creem_product_id` varchar(128) DEFAULT ''"}, {Name: "creem_product_id", DDL: "`creem_product_id` varchar(128) DEFAULT ''"},
{Name: "waffo_pancake_product_id", DDL: "`waffo_pancake_product_id` varchar(128) DEFAULT ''"}, {Name: "waffo_pancake_product_id", DDL: "`waffo_pancake_product_id` varchar(128) DEFAULT ''"},
{Name: "max_purchase_per_user", DDL: "`max_purchase_per_user` integer DEFAULT 0"}, {Name: "max_purchase_per_user", DDL: "`max_purchase_per_user` integer DEFAULT 0"},
{Name: "upgrade_group", DDL: "`upgrade_group` varchar(64) DEFAULT ''"}, {Name: "upgrade_group", DDL: "`upgrade_group` varchar(64) DEFAULT ''"},
{Name: "downgrade_group", DDL: "`downgrade_group` varchar(64) DEFAULT ''"},
{Name: "total_amount", DDL: "`total_amount` bigint NOT NULL DEFAULT 0"}, {Name: "total_amount", DDL: "`total_amount` bigint NOT NULL DEFAULT 0"},
{Name: "quota_reset_period", DDL: "`quota_reset_period` varchar(16) DEFAULT 'never'"}, {Name: "quota_reset_period", DDL: "`quota_reset_period` varchar(16) DEFAULT 'never'"},
{Name: "quota_reset_custom_seconds", DDL: "`quota_reset_custom_seconds` bigint DEFAULT 0"}, {Name: "quota_reset_custom_seconds", DDL: "`quota_reset_custom_seconds` bigint DEFAULT 0"},
......
...@@ -162,6 +162,9 @@ type SubscriptionPlan struct { ...@@ -162,6 +162,9 @@ type SubscriptionPlan struct {
AllowBalancePay *bool `json:"allow_balance_pay" gorm:"default:true"` AllowBalancePay *bool `json:"allow_balance_pay" gorm:"default:true"`
// Allow falling back to wallet balance after subscription quota is exhausted (empty = true)
AllowWalletOverflow *bool `json:"allow_wallet_overflow" gorm:"default:true"`
StripePriceId string `json:"stripe_price_id" gorm:"type:varchar(128);default:''"` StripePriceId string `json:"stripe_price_id" gorm:"type:varchar(128);default:''"`
CreemProductId string `json:"creem_product_id" gorm:"type:varchar(128);default:''"` CreemProductId string `json:"creem_product_id" gorm:"type:varchar(128);default:''"`
WaffoPancakeProductId string `json:"waffo_pancake_product_id" gorm:"type:varchar(128);default:''"` WaffoPancakeProductId string `json:"waffo_pancake_product_id" gorm:"type:varchar(128);default:''"`
...@@ -172,6 +175,9 @@ type SubscriptionPlan struct { ...@@ -172,6 +175,9 @@ type SubscriptionPlan struct {
// Upgrade user group after purchase (empty = no change) // Upgrade user group after purchase (empty = no change)
UpgradeGroup string `json:"upgrade_group" gorm:"type:varchar(64);default:''"` UpgradeGroup string `json:"upgrade_group" gorm:"type:varchar(64);default:''"`
// Downgrade user group on expiry (empty = revert to the group held before purchase)
DowngradeGroup string `json:"downgrade_group" gorm:"type:varchar(64);default:''"`
// Total quota (amount in quota units, 0 = unlimited) // Total quota (amount in quota units, 0 = unlimited)
TotalAmount int64 `json:"total_amount" gorm:"type:bigint;not null;default:0"` TotalAmount int64 `json:"total_amount" gorm:"type:bigint;not null;default:0"`
...@@ -199,6 +205,9 @@ func (p *SubscriptionPlan) NormalizeDefaults() { ...@@ -199,6 +205,9 @@ func (p *SubscriptionPlan) NormalizeDefaults() {
if p.AllowBalancePay == nil { if p.AllowBalancePay == nil {
p.AllowBalancePay = common.GetPointer(true) p.AllowBalancePay = common.GetPointer(true)
} }
if p.AllowWalletOverflow == nil {
p.AllowWalletOverflow = common.GetPointer(true)
}
} }
// Subscription order (payment -> webhook -> create UserSubscription) // Subscription order (payment -> webhook -> create UserSubscription)
...@@ -261,6 +270,12 @@ type UserSubscription struct { ...@@ -261,6 +270,12 @@ type UserSubscription struct {
UpgradeGroup string `json:"upgrade_group" gorm:"type:varchar(64);default:''"` UpgradeGroup string `json:"upgrade_group" gorm:"type:varchar(64);default:''"`
PrevUserGroup string `json:"prev_user_group" gorm:"type:varchar(64);default:''"` PrevUserGroup string `json:"prev_user_group" gorm:"type:varchar(64);default:''"`
// Downgrade target group on expiry (snapshot from plan; empty = revert to PrevUserGroup)
DowngradeGroup string `json:"downgrade_group" gorm:"type:varchar(64);default:''"`
// Whether wallet fallback is allowed after this subscription's quota is exhausted (snapshot from plan)
AllowWalletOverflow bool `json:"allow_wallet_overflow" gorm:"default:true"`
CreatedAt int64 `json:"created_at" gorm:"bigint"` CreatedAt int64 `json:"created_at" gorm:"bigint"`
UpdatedAt int64 `json:"updated_at" gorm:"bigint"` UpdatedAt int64 `json:"updated_at" gorm:"bigint"`
} }
...@@ -416,17 +431,17 @@ func downgradeUserGroupForSubscriptionTx(tx *gorm.DB, sub *UserSubscription, now ...@@ -416,17 +431,17 @@ func downgradeUserGroupForSubscriptionTx(tx *gorm.DB, sub *UserSubscription, now
if tx == nil || sub == nil { if tx == nil || sub == nil {
return "", errors.New("invalid downgrade args") return "", errors.New("invalid downgrade args")
} }
downgradeGroup := strings.TrimSpace(sub.DowngradeGroup)
upgradeGroup := strings.TrimSpace(sub.UpgradeGroup) upgradeGroup := strings.TrimSpace(sub.UpgradeGroup)
if upgradeGroup == "" { // Nothing to do if neither an explicit downgrade target nor an upgrade snapshot exists.
if downgradeGroup == "" && upgradeGroup == "" {
return "", nil return "", nil
} }
currentGroup, err := getUserGroupByIdTx(tx, sub.UserId) currentGroup, err := getUserGroupByIdTx(tx, sub.UserId)
if err != nil { if err != nil {
return "", err return "", err
} }
if currentGroup != upgradeGroup { // If another active upgraded subscription exists, keep the current group.
return "", nil
}
var activeSub UserSubscription var activeSub UserSubscription
activeQuery := tx.Where("user_id = ? AND status = ? AND end_time > ? AND id <> ? AND upgrade_group <> ''", activeQuery := tx.Where("user_id = ? AND status = ? AND end_time > ? AND id <> ? AND upgrade_group <> ''",
sub.UserId, "active", now, sub.Id). sub.UserId, "active", now, sub.Id).
...@@ -436,15 +451,24 @@ func downgradeUserGroupForSubscriptionTx(tx *gorm.DB, sub *UserSubscription, now ...@@ -436,15 +451,24 @@ func downgradeUserGroupForSubscriptionTx(tx *gorm.DB, sub *UserSubscription, now
if activeQuery.Error == nil && activeQuery.RowsAffected > 0 { if activeQuery.Error == nil && activeQuery.RowsAffected > 0 {
return "", nil return "", nil
} }
prevGroup := strings.TrimSpace(sub.PrevUserGroup) // Determine the downgrade target: an explicit downgrade group takes precedence,
if prevGroup == "" || prevGroup == currentGroup { // otherwise revert to the group held before purchase (legacy behavior).
target := downgradeGroup
if target == "" {
// Legacy behavior: only revert when the subscription actually elevated the user.
if currentGroup != upgradeGroup {
return "", nil
}
target = strings.TrimSpace(sub.PrevUserGroup)
}
if target == "" || target == currentGroup {
return "", nil return "", nil
} }
if err := tx.Model(&User{}).Where("id = ?", sub.UserId). if err := tx.Model(&User{}).Where("id = ?", sub.UserId).
Update("group", prevGroup).Error; err != nil { Update("group", target).Error; err != nil {
return "", err return "", err
} }
return prevGroup, nil return target, nil
} }
func CreateUserSubscriptionFromPlanTx(tx *gorm.DB, userId int, plan *SubscriptionPlan, source string) (*UserSubscription, error) { func CreateUserSubscriptionFromPlanTx(tx *gorm.DB, userId int, plan *SubscriptionPlan, source string) (*UserSubscription, error) {
...@@ -495,6 +519,10 @@ func CreateUserSubscriptionFromPlanTx(tx *gorm.DB, userId int, plan *Subscriptio ...@@ -495,6 +519,10 @@ func CreateUserSubscriptionFromPlanTx(tx *gorm.DB, userId int, plan *Subscriptio
} }
} }
} }
allowWalletOverflow := true
if plan.AllowWalletOverflow != nil {
allowWalletOverflow = *plan.AllowWalletOverflow
}
sub := &UserSubscription{ sub := &UserSubscription{
UserId: userId, UserId: userId,
PlanId: plan.Id, PlanId: plan.Id,
...@@ -508,6 +536,8 @@ func CreateUserSubscriptionFromPlanTx(tx *gorm.DB, userId int, plan *Subscriptio ...@@ -508,6 +536,8 @@ func CreateUserSubscriptionFromPlanTx(tx *gorm.DB, userId int, plan *Subscriptio
NextResetTime: nextReset, NextResetTime: nextReset,
UpgradeGroup: upgradeGroup, UpgradeGroup: upgradeGroup,
PrevUserGroup: prevGroup, PrevUserGroup: prevGroup,
DowngradeGroup: strings.TrimSpace(plan.DowngradeGroup),
AllowWalletOverflow: allowWalletOverflow,
CreatedAt: common.GetTimestamp(), CreatedAt: common.GetTimestamp(),
UpdatedAt: common.GetTimestamp(), UpdatedAt: common.GetTimestamp(),
} }
...@@ -811,6 +841,24 @@ func HasActiveUserSubscription(userId int) (bool, error) { ...@@ -811,6 +841,24 @@ func HasActiveUserSubscription(userId int) (bool, error) {
return count > 0, nil return count > 0, nil
} }
// UserActiveSubscriptionsAllowWalletOverflow returns whether wallet balance may be used
// after the user's subscription quota is exhausted. A single active subscription that
// disallows wallet overflow (allow_wallet_overflow = false) blocks the fallback.
func UserActiveSubscriptionsAllowWalletOverflow(userId int) (bool, error) {
if userId <= 0 {
return false, errors.New("invalid userId")
}
now := common.GetTimestamp()
var strictCount int64
if err := DB.Model(&UserSubscription{}).
Where("user_id = ? AND status = ? AND end_time > ? AND allow_wallet_overflow = ?",
userId, "active", now, false).
Count(&strictCount).Error; err != nil {
return false, err
}
return strictCount == 0, nil
}
// GetAllUserSubscriptions returns all subscriptions (active and expired) for a user. // GetAllUserSubscriptions returns all subscriptions (active and expired) for a user.
func GetAllUserSubscriptions(userId int) ([]SubscriptionSummary, error) { func GetAllUserSubscriptions(userId int) ([]SubscriptionSummary, error) {
if userId <= 0 { if userId <= 0 {
...@@ -982,9 +1030,10 @@ func ExpireDueSubscriptions(limit int) (int, error) { ...@@ -982,9 +1030,10 @@ func ExpireDueSubscriptions(limit int) (int, error) {
return nil return nil
} }
// No active upgraded subscription, downgrade to previous group if needed. // Find the most recently expired subscription that defines a group transition
// (an explicit downgrade target or an upgrade snapshot to revert).
var lastExpired UserSubscription var lastExpired UserSubscription
expiredQuery := tx.Where("user_id = ? AND status = ? AND upgrade_group <> ''", expiredQuery := tx.Where("user_id = ? AND status = ? AND (downgrade_group <> '' OR upgrade_group <> '')",
userId, "expired"). userId, "expired").
Order("end_time desc, id desc"). Order("end_time desc, id desc").
Limit(1). Limit(1).
...@@ -992,23 +1041,33 @@ func ExpireDueSubscriptions(limit int) (int, error) { ...@@ -992,23 +1041,33 @@ func ExpireDueSubscriptions(limit int) (int, error) {
if expiredQuery.Error != nil || expiredQuery.RowsAffected == 0 { if expiredQuery.Error != nil || expiredQuery.RowsAffected == 0 {
return nil return nil
} }
currentGroup, err := getUserGroupByIdTx(tx, userId)
if err != nil {
return err
}
// An explicit downgrade group takes precedence; otherwise revert to the
// group held before purchase (legacy behavior, only when the subscription
// actually elevated the user).
target := strings.TrimSpace(lastExpired.DowngradeGroup)
if target == "" {
upgradeGroup := strings.TrimSpace(lastExpired.UpgradeGroup) upgradeGroup := strings.TrimSpace(lastExpired.UpgradeGroup)
prevGroup := strings.TrimSpace(lastExpired.PrevUserGroup) prevGroup := strings.TrimSpace(lastExpired.PrevUserGroup)
if upgradeGroup == "" || prevGroup == "" { if upgradeGroup == "" || prevGroup == "" {
return nil return nil
} }
currentGroup, err := getUserGroupByIdTx(tx, userId) if currentGroup != upgradeGroup {
if err != nil { return nil
return err
} }
if currentGroup != upgradeGroup || currentGroup == prevGroup { target = prevGroup
}
if target == "" || target == currentGroup {
return nil return nil
} }
if err := tx.Model(&User{}).Where("id = ?", userId). if err := tx.Model(&User{}).Where("id = ?", userId).
Update("group", prevGroup).Error; err != nil { Update("group", target).Error; err != nil {
return err return err
} }
cacheGroup = prevGroup cacheGroup = target
return nil return nil
}) })
if err != nil { if err != nil {
......
...@@ -425,10 +425,18 @@ func NewBillingSession(c *gin.Context, relayInfo *relaycommon.RelayInfo, preCons ...@@ -425,10 +425,18 @@ func NewBillingSession(c *gin.Context, relayInfo *relaycommon.RelayInfo, preCons
session, apiErr := trySubscription() session, apiErr := trySubscription()
if apiErr != nil { if apiErr != nil {
if apiErr.GetErrorCode() == types.ErrorCodeInsufficientUserQuota { if apiErr.GetErrorCode() == types.ErrorCodeInsufficientUserQuota {
// 仅当用户的活跃订阅允许钱包回退时才回退到钱包,否则返回订阅额度不足错误
allowOverflow, overflowErr := model.UserActiveSubscriptionsAllowWalletOverflow(relayInfo.UserId)
if overflowErr != nil {
return nil, types.NewError(overflowErr, types.ErrorCodeQueryDataError, types.ErrOptionWithSkipRetry())
}
if allowOverflow {
return tryWallet() return tryWallet()
} }
return nil, apiErr return nil, apiErr
} }
return nil, apiErr
}
return session, nil return session, nil
} }
} }
...@@ -297,7 +297,7 @@ export function SubscriptionPurchaseDialog(props: Props) { ...@@ -297,7 +297,7 @@ export function SubscriptionPurchaseDialog(props: Props) {
)} )}
<div className='flex items-center justify-between'> <div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'> <span className='text-muted-foreground text-sm'>
{t('Received amount')} {t('Plan Quota')}
</span> </span>
<span className='flex items-center gap-1 text-sm'> <span className='flex items-center gap-1 text-sm'>
<Package className='h-3.5 w-3.5' /> <Package className='h-3.5 w-3.5' />
......
...@@ -52,6 +52,7 @@ import { ...@@ -52,6 +52,7 @@ import {
invalidateUserSubscription, invalidateUserSubscription,
deleteUserSubscription, deleteUserSubscription,
} from '../../api' } from '../../api'
import { formatQuota } from '@/lib/format'
import { formatTimestamp } from '../../lib' import { formatTimestamp } from '../../lib'
import type { PlanRecord, UserSubscriptionRecord } from '../../types' import type { PlanRecord, UserSubscriptionRecord } from '../../types'
...@@ -301,7 +302,9 @@ export function UserSubscriptionsDialog(props: Props) { ...@@ -301,7 +302,9 @@ export function UserSubscriptionsDialog(props: Props) {
const sub = record.subscription const sub = record.subscription
const total = Number(sub.amount_total || 0) const total = Number(sub.amount_total || 0)
const used = Number(sub.amount_used || 0) const used = Number(sub.amount_used || 0)
return total > 0 ? `${used}/${total}` : t('Unlimited') return total > 0
? `${formatQuota(used)}/${formatQuota(total)}`
: t('Unlimited')
}, },
}, },
{ {
......
...@@ -160,7 +160,7 @@ export function useSubscriptionsColumns(): ColumnDef<PlanRecord>[] { ...@@ -160,7 +160,7 @@ export function useSubscriptionsColumns(): ColumnDef<PlanRecord>[] {
}, },
{ {
id: 'total_amount', id: 'total_amount',
header: t('Received amount'), header: t('Plan Quota'),
meta: { mobileHidden: true }, meta: { mobileHidden: true },
cell: ({ row }) => { cell: ({ row }) => {
const total = Number(row.original.plan.total_amount || 0) const total = Number(row.original.plan.total_amount || 0)
......
...@@ -66,6 +66,7 @@ import { ...@@ -66,6 +66,7 @@ import {
createWaffoPancakeSubscriptionProduct, createWaffoPancakeSubscriptionProduct,
listWaffoPancakeSubscriptionProductOptions, listWaffoPancakeSubscriptionProductOptions,
} from '../api' } from '../api'
import { getCurrencyDisplay, getCurrencyLabel } from '@/lib/currency'
import { getDurationUnitOptions, getResetPeriodOptions } from '../constants' import { getDurationUnitOptions, getResetPeriodOptions } from '../constants'
import { import {
getPlanFormSchema, getPlanFormSchema,
...@@ -91,6 +92,9 @@ export function SubscriptionsMutateDrawer({ ...@@ -91,6 +92,9 @@ export function SubscriptionsMutateDrawer({
const { t } = useTranslation() const { t } = useTranslation()
const isEdit = !!currentRow?.plan?.id const isEdit = !!currentRow?.plan?.id
const { triggerRefresh } = useSubscriptions() const { triggerRefresh } = useSubscriptions()
const { meta: currencyMeta } = getCurrencyDisplay()
const tokensOnly = currencyMeta.kind === 'tokens'
const currencyLabel = getCurrencyLabel()
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
const [groupOptions, setGroupOptions] = useState<string[]>([]) const [groupOptions, setGroupOptions] = useState<string[]>([])
const [creatingPancakeProduct, setCreatingPancakeProduct] = useState(false) const [creatingPancakeProduct, setCreatingPancakeProduct] = useState(false)
...@@ -314,7 +318,7 @@ export function SubscriptionsMutateDrawer({ ...@@ -314,7 +318,7 @@ export function SubscriptionsMutateDrawer({
name='price_amount' name='price_amount'
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>{t('Actual Amount')}</FormLabel> <FormLabel>{t('Plan Price')}</FormLabel>
<FormControl> <FormControl>
<Input <Input
{...field} {...field}
...@@ -336,12 +340,22 @@ export function SubscriptionsMutateDrawer({ ...@@ -336,12 +340,22 @@ export function SubscriptionsMutateDrawer({
name='total_amount' name='total_amount'
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>{t('Received amount')}</FormLabel> <FormLabel>
{t('Quota ({{currency}})', { currency: currencyLabel })}
</FormLabel>
<FormControl> <FormControl>
<Input <Input
{...field} {...field}
type='number' type='number'
min={0} min={0}
step={tokensOnly ? 1 : 0.01}
placeholder={
tokensOnly
? t('Enter quota in tokens')
: t('Enter quota in {{currency}}', {
currency: currencyLabel,
})
}
onChange={(e) => onChange={(e) =>
field.onChange(parseFloat(e.target.value) || 0) field.onChange(parseFloat(e.target.value) || 0)
} }
...@@ -349,7 +363,7 @@ export function SubscriptionsMutateDrawer({ ...@@ -349,7 +363,7 @@ export function SubscriptionsMutateDrawer({
</FormControl> </FormControl>
<FormDescription> <FormDescription>
{t( {t(
'0 means unlimited. The value is converted to quota units when saved.' 'Total quota included in the plan, usable per billing period. 0 means unlimited.'
)} )}
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
...@@ -400,6 +414,53 @@ export function SubscriptionsMutateDrawer({ ...@@ -400,6 +414,53 @@ export function SubscriptionsMutateDrawer({
<FormField <FormField
control={form.control} control={form.control}
name='downgrade_group'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Downgrade Group')}</FormLabel>
<Select
items={[
{
value: '__none__',
label: t('Downgrade to pre-purchase group'),
},
...groupOptions.map((g) => ({ value: g, label: g })),
]}
onValueChange={(v) =>
field.onChange(v === '__none__' ? '' : v)
}
value={field.value || ''}
>
<FormControl>
<SelectTrigger>
<SelectValue
placeholder={t('Downgrade to pre-purchase group')}
/>
</SelectTrigger>
</FormControl>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
<SelectItem value='__none__'>
{t('Downgrade to pre-purchase group')}
</SelectItem>
{groupOptions.map((g) => (
<SelectItem key={g} value={g}>
{g}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<FormDescription>
{t('Downgrade to this group after the subscription expires')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='max_purchase_per_user' name='max_purchase_per_user'
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
...@@ -423,7 +484,6 @@ export function SubscriptionsMutateDrawer({ ...@@ -423,7 +484,6 @@ export function SubscriptionsMutateDrawer({
/> />
</div> </div>
<div className='grid grid-cols-1 gap-3 sm:grid-cols-2'>
<FormField <FormField
control={form.control} control={form.control}
name='sort_order' name='sort_order'
...@@ -444,6 +504,7 @@ export function SubscriptionsMutateDrawer({ ...@@ -444,6 +504,7 @@ export function SubscriptionsMutateDrawer({
)} )}
/> />
<div className='flex flex-col gap-3'>
<FormField <FormField
control={form.control} control={form.control}
name='enabled' name='enabled'
...@@ -479,6 +540,24 @@ export function SubscriptionsMutateDrawer({ ...@@ -479,6 +540,24 @@ export function SubscriptionsMutateDrawer({
</FormItem> </FormItem>
)} )}
/> />
<FormField
control={form.control}
name='allow_wallet_overflow'
render={({ field }) => (
<FormItem className={sideDrawerSwitchItemClassName()}>
<FormLabel className='!mt-0'>
{t('Allow wallet balance after quota used up')}
</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</div> </div>
</SideDrawerSection> </SideDrawerSection>
......
...@@ -40,9 +40,11 @@ export function getPlanFormSchema(t: TFunction) { ...@@ -40,9 +40,11 @@ export function getPlanFormSchema(t: TFunction) {
enabled: z.boolean(), enabled: z.boolean(),
sort_order: z.coerce.number(), sort_order: z.coerce.number(),
allow_balance_pay: z.boolean(), allow_balance_pay: z.boolean(),
allow_wallet_overflow: z.boolean(),
max_purchase_per_user: z.coerce.number().min(0), max_purchase_per_user: z.coerce.number().min(0),
total_amount: z.coerce.number().min(0), total_amount: z.coerce.number().min(0),
upgrade_group: z.string().optional(), upgrade_group: z.string().optional(),
downgrade_group: z.string().optional(),
stripe_price_id: z.string().optional(), stripe_price_id: z.string().optional(),
creem_product_id: z.string().optional(), creem_product_id: z.string().optional(),
waffo_pancake_product_id: z.string().optional(), waffo_pancake_product_id: z.string().optional(),
...@@ -63,9 +65,11 @@ export const PLAN_FORM_DEFAULTS: PlanFormValues = { ...@@ -63,9 +65,11 @@ export const PLAN_FORM_DEFAULTS: PlanFormValues = {
enabled: true, enabled: true,
sort_order: 0, sort_order: 0,
allow_balance_pay: true, allow_balance_pay: true,
allow_wallet_overflow: true,
max_purchase_per_user: 0, max_purchase_per_user: 0,
total_amount: 0, total_amount: 0,
upgrade_group: '', upgrade_group: '',
downgrade_group: '',
stripe_price_id: '', stripe_price_id: '',
creem_product_id: '', creem_product_id: '',
waffo_pancake_product_id: '', waffo_pancake_product_id: '',
...@@ -84,9 +88,11 @@ export function planToFormValues(plan: SubscriptionPlan): PlanFormValues { ...@@ -84,9 +88,11 @@ export function planToFormValues(plan: SubscriptionPlan): PlanFormValues {
enabled: plan.enabled !== false, enabled: plan.enabled !== false,
sort_order: Number(plan.sort_order || 0), sort_order: Number(plan.sort_order || 0),
allow_balance_pay: plan.allow_balance_pay !== false, allow_balance_pay: plan.allow_balance_pay !== false,
allow_wallet_overflow: plan.allow_wallet_overflow !== false,
max_purchase_per_user: Number(plan.max_purchase_per_user || 0), max_purchase_per_user: Number(plan.max_purchase_per_user || 0),
total_amount: quotaUnitsToDollars(Number(plan.total_amount || 0)), total_amount: quotaUnitsToDollars(Number(plan.total_amount || 0)),
upgrade_group: plan.upgrade_group || '', upgrade_group: plan.upgrade_group || '',
downgrade_group: plan.downgrade_group || '',
stripe_price_id: plan.stripe_price_id || '', stripe_price_id: plan.stripe_price_id || '',
creem_product_id: plan.creem_product_id || '', creem_product_id: plan.creem_product_id || '',
waffo_pancake_product_id: plan.waffo_pancake_product_id || '', waffo_pancake_product_id: plan.waffo_pancake_product_id || '',
...@@ -110,6 +116,7 @@ export function formValuesToPlanPayload(values: PlanFormValues): PlanPayload { ...@@ -110,6 +116,7 @@ export function formValuesToPlanPayload(values: PlanFormValues): PlanPayload {
max_purchase_per_user: Number(values.max_purchase_per_user || 0), max_purchase_per_user: Number(values.max_purchase_per_user || 0),
total_amount: parseQuotaFromDollars(Number(values.total_amount || 0)), total_amount: parseQuotaFromDollars(Number(values.total_amount || 0)),
upgrade_group: values.upgrade_group || '', upgrade_group: values.upgrade_group || '',
downgrade_group: values.downgrade_group || '',
}, },
} }
} }
...@@ -36,9 +36,11 @@ export const subscriptionPlanSchema = z.object({ ...@@ -36,9 +36,11 @@ export const subscriptionPlanSchema = z.object({
enabled: z.boolean(), enabled: z.boolean(),
sort_order: z.number(), sort_order: z.number(),
allow_balance_pay: z.boolean().optional().default(true), allow_balance_pay: z.boolean().optional().default(true),
allow_wallet_overflow: z.boolean().optional().default(true),
max_purchase_per_user: z.number(), max_purchase_per_user: z.number(),
total_amount: z.number(), total_amount: z.number(),
upgrade_group: z.string().optional(), upgrade_group: z.string().optional(),
downgrade_group: z.string().optional(),
stripe_price_id: z.string().optional(), stripe_price_id: z.string().optional(),
creem_product_id: z.string().optional(), creem_product_id: z.string().optional(),
waffo_pancake_product_id: z.string().optional(), waffo_pancake_product_id: z.string().optional(),
......
...@@ -138,6 +138,9 @@ ...@@ -138,6 +138,9 @@
"Active models": "Active models", "Active models": "Active models",
"active users": "active users", "active users": "active users",
"Actual Amount": "Actual Amount", "Actual Amount": "Actual Amount",
"Plan Price": "Plan Price",
"Plan Quota": "Plan Quota",
"Total quota included in the plan, usable per billing period. 0 means unlimited.": "Total quota included in the plan, usable per billing period. 0 means unlimited.",
"Actual Model": "Actual Model", "Actual Model": "Actual Model",
"Actual Model:": "Actual Model:", "Actual Model:": "Actual Model:",
"Adapt `-thinking` suffix requests to Anthropic native thinking behavior while keeping billing predictable.": "Adapt `-thinking` suffix requests to Anthropic native thinking behavior while keeping billing predictable.", "Adapt `-thinking` suffix requests to Anthropic native thinking behavior while keeping billing predictable.": "Adapt `-thinking` suffix requests to Anthropic native thinking behavior while keeping billing predictable.",
...@@ -276,6 +279,10 @@ ...@@ -276,6 +279,10 @@
"Allocated Memory": "Allocated Memory", "Allocated Memory": "Allocated Memory",
"Allow accountFilter parameter": "Allow accountFilter parameter", "Allow accountFilter parameter": "Allow accountFilter parameter",
"Allow balance redemption": "Allow balance redemption", "Allow balance redemption": "Allow balance redemption",
"Allow wallet balance after quota used up": "Allow wallet balance after quota used up",
"Downgrade Group": "Downgrade Group",
"Downgrade to pre-purchase group": "Downgrade to pre-purchase group",
"Downgrade to this group after the subscription expires": "Downgrade to this group after the subscription expires",
"Allow Claude beta query passthrough": "Allow Claude beta query passthrough", "Allow Claude beta query passthrough": "Allow Claude beta query passthrough",
"Allow clients to query configured ratios via `/api/ratio`.": "Allow clients to query configured ratios via `/api/ratio`.", "Allow clients to query configured ratios via `/api/ratio`.": "Allow clients to query configured ratios via `/api/ratio`.",
"Allow HTTP image requests": "Allow HTTP image requests", "Allow HTTP image requests": "Allow HTTP image requests",
......
...@@ -138,6 +138,9 @@ ...@@ -138,6 +138,9 @@
"Active models": "Modèles actifs", "Active models": "Modèles actifs",
"active users": "utilisateurs actifs", "active users": "utilisateurs actifs",
"Actual Amount": "Montant réel", "Actual Amount": "Montant réel",
"Plan Price": "Prix du forfait",
"Plan Quota": "Quota du forfait",
"Total quota included in the plan, usable per billing period. 0 means unlimited.": "Quota total inclus dans le forfait, utilisable par période de facturation. 0 signifie illimité.",
"Actual Model": "Modèle réel", "Actual Model": "Modèle réel",
"Actual Model:": "Modèle réel :", "Actual Model:": "Modèle réel :",
"Adapt `-thinking` suffix requests to Anthropic native thinking behavior while keeping billing predictable.": "Adapter les requêtes avec le suffixe `-thinking` au comportement de pensée natif d’Anthropic tout en gardant une facturation prévisible.", "Adapt `-thinking` suffix requests to Anthropic native thinking behavior while keeping billing predictable.": "Adapter les requêtes avec le suffixe `-thinking` au comportement de pensée natif d’Anthropic tout en gardant une facturation prévisible.",
...@@ -276,6 +279,10 @@ ...@@ -276,6 +279,10 @@
"Allocated Memory": "Mémoire allouée", "Allocated Memory": "Mémoire allouée",
"Allow accountFilter parameter": "Autoriser le paramètre accountFilter", "Allow accountFilter parameter": "Autoriser le paramètre accountFilter",
"Allow balance redemption": "Autoriser le paiement avec le solde", "Allow balance redemption": "Autoriser le paiement avec le solde",
"Allow wallet balance after quota used up": "Autoriser le solde du portefeuille une fois le quota épuisé",
"Downgrade Group": "Groupe de rétrogradation",
"Downgrade to pre-purchase group": "Rétrograder vers le groupe d'avant l'achat",
"Downgrade to this group after the subscription expires": "Rétrograder vers ce groupe après l'expiration de l'abonnement",
"Allow Claude beta query passthrough": "Autoriser le passage des requêtes bêta Claude", "Allow Claude beta query passthrough": "Autoriser le passage des requêtes bêta Claude",
"Allow clients to query configured ratios via `/api/ratio`.": "Autoriser les clients à interroger les ratios configurés via `/api/ratio`.", "Allow clients to query configured ratios via `/api/ratio`.": "Autoriser les clients à interroger les ratios configurés via `/api/ratio`.",
"Allow HTTP image requests": "Autoriser les requêtes d'images HTTP", "Allow HTTP image requests": "Autoriser les requêtes d'images HTTP",
......
...@@ -138,6 +138,9 @@ ...@@ -138,6 +138,9 @@
"Active models": "アクティブなモデル", "Active models": "アクティブなモデル",
"active users": "アクティブユーザー", "active users": "アクティブユーザー",
"Actual Amount": "実際の金額", "Actual Amount": "実際の金額",
"Plan Price": "プラン価格",
"Plan Quota": "プランのクォータ",
"Total quota included in the plan, usable per billing period. 0 means unlimited.": "プランに含まれる合計クォータ。請求期間ごとに使用可能。0 は無制限を意味します。",
"Actual Model": "実際のモデル", "Actual Model": "実際のモデル",
"Actual Model:": "実際のモデル:", "Actual Model:": "実際のモデル:",
"Adapt `-thinking` suffix requests to Anthropic native thinking behavior while keeping billing predictable.": "`-thinking` サフィックス付きリクエストを Anthropic ネイティブの思考動作に適配し、課金を予測可能に保ちます。", "Adapt `-thinking` suffix requests to Anthropic native thinking behavior while keeping billing predictable.": "`-thinking` サフィックス付きリクエストを Anthropic ネイティブの思考動作に適配し、課金を予測可能に保ちます。",
...@@ -276,6 +279,10 @@ ...@@ -276,6 +279,10 @@
"Allocated Memory": "割り当て済みメモリ", "Allocated Memory": "割り当て済みメモリ",
"Allow accountFilter parameter": "accountFilter パラメータを許可", "Allow accountFilter parameter": "accountFilter パラメータを許可",
"Allow balance redemption": "残高での交換を許可", "Allow balance redemption": "残高での交換を許可",
"Allow wallet balance after quota used up": "クォータ使い切り後にウォレット残高の使用を許可",
"Downgrade Group": "ダウングレードグループ",
"Downgrade to pre-purchase group": "購入前のグループにダウングレード",
"Downgrade to this group after the subscription expires": "サブスクリプションの有効期限が切れた後、このグループにダウングレードします",
"Allow Claude beta query passthrough": "Claude ベータクエリのパススルーを許可", "Allow Claude beta query passthrough": "Claude ベータクエリのパススルーを許可",
"Allow clients to query configured ratios via `/api/ratio`.": "クライアントが `/api/ratio` 経由で設定された比率を照会できるようにします。", "Allow clients to query configured ratios via `/api/ratio`.": "クライアントが `/api/ratio` 経由で設定された比率を照会できるようにします。",
"Allow HTTP image requests": "HTTP画像リクエストを許可", "Allow HTTP image requests": "HTTP画像リクエストを許可",
......
...@@ -138,6 +138,9 @@ ...@@ -138,6 +138,9 @@
"Active models": "Активные модели", "Active models": "Активные модели",
"active users": "активных пользователей", "active users": "активных пользователей",
"Actual Amount": "Фактическая сумма", "Actual Amount": "Фактическая сумма",
"Plan Price": "Цена тарифа",
"Plan Quota": "Квота тарифа",
"Total quota included in the plan, usable per billing period. 0 means unlimited.": "Общая квота, включённая в тариф, доступна за каждый расчётный период. 0 означает безлимит.",
"Actual Model": "Фактическая модель", "Actual Model": "Фактическая модель",
"Actual Model:": "Фактическая модель:", "Actual Model:": "Фактическая модель:",
"Adapt `-thinking` suffix requests to Anthropic native thinking behavior while keeping billing predictable.": "Адаптировать запросы с суффиксом `-thinking` к собственному режиму размышления Anthropic, сохраняя предсказуемость биллинга.", "Adapt `-thinking` suffix requests to Anthropic native thinking behavior while keeping billing predictable.": "Адаптировать запросы с суффиксом `-thinking` к собственному режиму размышления Anthropic, сохраняя предсказуемость биллинга.",
...@@ -276,6 +279,10 @@ ...@@ -276,6 +279,10 @@
"Allocated Memory": "Выделенная память", "Allocated Memory": "Выделенная память",
"Allow accountFilter parameter": "Разрешить параметр accountFilter", "Allow accountFilter parameter": "Разрешить параметр accountFilter",
"Allow balance redemption": "Разрешить оплату балансом", "Allow balance redemption": "Разрешить оплату балансом",
"Allow wallet balance after quota used up": "Разрешить использование баланса кошелька после исчерпания квоты",
"Downgrade Group": "Понизить группу",
"Downgrade to pre-purchase group": "Понизить до группы до покупки",
"Downgrade to this group after the subscription expires": "Понизить до этой группы после истечения подписки",
"Allow Claude beta query passthrough": "Разрешить проброс бета-запросов Claude", "Allow Claude beta query passthrough": "Разрешить проброс бета-запросов Claude",
"Allow clients to query configured ratios via `/api/ratio`.": "Разрешить клиентам запрашивать настроенные соотношения через `/api/ratio`.", "Allow clients to query configured ratios via `/api/ratio`.": "Разрешить клиентам запрашивать настроенные соотношения через `/api/ratio`.",
"Allow HTTP image requests": "Разрешить HTTP-запросы изображений", "Allow HTTP image requests": "Разрешить HTTP-запросы изображений",
......
...@@ -138,6 +138,9 @@ ...@@ -138,6 +138,9 @@
"Active models": "Mô hình đang hoạt động", "Active models": "Mô hình đang hoạt động",
"active users": "Người dùng tích cực", "active users": "Người dùng tích cực",
"Actual Amount": "Số tiền thực tế", "Actual Amount": "Số tiền thực tế",
"Plan Price": "Giá gói",
"Plan Quota": "Hạn ngạch gói",
"Total quota included in the plan, usable per billing period. 0 means unlimited.": "Tổng hạn ngạch bao gồm trong gói, dùng được mỗi kỳ thanh toán. 0 nghĩa là không giới hạn.",
"Actual Model": "Mô hình thực tế", "Actual Model": "Mô hình thực tế",
"Actual Model:": "Mô hình thực tế:", "Actual Model:": "Mô hình thực tế:",
"Adapt `-thinking` suffix requests to Anthropic native thinking behavior while keeping billing predictable.": "Điều chỉnh các yêu cầu có hậu tố `-thinking` sang hành vi suy luận gốc của Anthropic trong khi vẫn giữ tính phí dễ dự đoán.", "Adapt `-thinking` suffix requests to Anthropic native thinking behavior while keeping billing predictable.": "Điều chỉnh các yêu cầu có hậu tố `-thinking` sang hành vi suy luận gốc của Anthropic trong khi vẫn giữ tính phí dễ dự đoán.",
...@@ -276,6 +279,10 @@ ...@@ -276,6 +279,10 @@
"Allocated Memory": "Bộ nhớ đã cấp phát", "Allocated Memory": "Bộ nhớ đã cấp phát",
"Allow accountFilter parameter": "Cho phép tham số accountFilter", "Allow accountFilter parameter": "Cho phép tham số accountFilter",
"Allow balance redemption": "Cho phép thanh toán bằng số dư", "Allow balance redemption": "Cho phép thanh toán bằng số dư",
"Allow wallet balance after quota used up": "Cho phép dùng số dư ví sau khi dùng hết hạn ngạch",
"Downgrade Group": "Nhóm hạ cấp",
"Downgrade to pre-purchase group": "Hạ xuống nhóm trước khi mua",
"Downgrade to this group after the subscription expires": "Hạ xuống nhóm này sau khi đăng ký hết hạn",
"Allow Claude beta query passthrough": "Cho phép chuyển tiếp truy vấn beta Claude", "Allow Claude beta query passthrough": "Cho phép chuyển tiếp truy vấn beta Claude",
"Allow clients to query configured ratios via `/api/ratio`.": "Cho phép khách hàng truy vấn các tỷ lệ đã cấu hình thông qua `/api/ratio`.", "Allow clients to query configured ratios via `/api/ratio`.": "Cho phép khách hàng truy vấn các tỷ lệ đã cấu hình thông qua `/api/ratio`.",
"Allow HTTP image requests": "Cho phép yêu cầu hình ảnh HTTP", "Allow HTTP image requests": "Cho phép yêu cầu hình ảnh HTTP",
......
...@@ -138,6 +138,9 @@ ...@@ -138,6 +138,9 @@
"Active models": "活跃模型", "Active models": "活跃模型",
"active users": "活跃用户", "active users": "活跃用户",
"Actual Amount": "实付金额", "Actual Amount": "实付金额",
"Plan Price": "套餐价格",
"Plan Quota": "套餐额度",
"Total quota included in the plan, usable per billing period. 0 means unlimited.": "套餐包含的总额度,每个计费周期可用;0 表示不限量",
"Actual Model": "实际模型", "Actual Model": "实际模型",
"Actual Model:": "实际模型:", "Actual Model:": "实际模型:",
"Adapt `-thinking` suffix requests to Anthropic native thinking behavior while keeping billing predictable.": "将带 `-thinking` 后缀的请求适配为 Anthropic 原生思考请求,并保持计费可预测。", "Adapt `-thinking` suffix requests to Anthropic native thinking behavior while keeping billing predictable.": "将带 `-thinking` 后缀的请求适配为 Anthropic 原生思考请求,并保持计费可预测。",
...@@ -276,6 +279,10 @@ ...@@ -276,6 +279,10 @@
"Allocated Memory": "已分配内存", "Allocated Memory": "已分配内存",
"Allow accountFilter parameter": "允许 accountFilter 参数", "Allow accountFilter parameter": "允许 accountFilter 参数",
"Allow balance redemption": "允许余额兑换", "Allow balance redemption": "允许余额兑换",
"Allow wallet balance after quota used up": "额度用尽后允许使用钱包余额",
"Downgrade Group": "降级分组",
"Downgrade to pre-purchase group": "降级到购买前分组",
"Downgrade to this group after the subscription expires": "订阅过期后降级到该分组",
"Allow Claude beta query passthrough": "允许 Claude beta 查询透传", "Allow Claude beta query passthrough": "允许 Claude beta 查询透传",
"Allow clients to query configured ratios via `/api/ratio`.": "允许客户端通过 `/api/ratio` 查询配置的比例。", "Allow clients to query configured ratios via `/api/ratio`.": "允许客户端通过 `/api/ratio` 查询配置的比例。",
"Allow HTTP image requests": "允许 HTTP 图像请求", "Allow HTTP image requests": "允许 HTTP 图像请求",
......
...@@ -350,6 +350,10 @@ export const STATIC_I18N_KEYS = [ ...@@ -350,6 +350,10 @@ export const STATIC_I18N_KEYS = [
'Priority', 'Priority',
'Payment Channel', 'Payment Channel',
'No Upgrade', 'No Upgrade',
'Downgrade to pre-purchase group',
'Downgrade Group',
'Downgrade to this group after the subscription expires',
'Allow wallet balance after quota used up',
'Unlimited', 'Unlimited',
'Update plan info', 'Update plan info',
'Create new subscription plan', 'Create new subscription plan',
...@@ -361,6 +365,9 @@ export const STATIC_I18N_KEYS = [ ...@@ -361,6 +365,9 @@ export const STATIC_I18N_KEYS = [
'Plan Subtitle', 'Plan Subtitle',
'e.g. Suitable for light usage', 'e.g. Suitable for light usage',
'Actual Amount', 'Actual Amount',
'Plan Price',
'Plan Quota',
'Total quota included in the plan, usable per billing period. 0 means unlimited.',
'Total Quota', 'Total Quota',
'0 means unlimited', '0 means unlimited',
'Sort Order', 'Sort Order',
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment