feat(subscription): add support for wallet overflow and downgrade group functionality in subscription plans

This commit is contained in:
CaIon
2026-06-18 16:44:12 +08:00
parent 4e8b5e9b13
commit f6c260437c
17 changed files with 291 additions and 59 deletions
@@ -297,7 +297,7 @@ export function SubscriptionPurchaseDialog(props: Props) {
)}
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>
{t('Received amount')}
{t('Plan Quota')}
</span>
<span className='flex items-center gap-1 text-sm'>
<Package className='h-3.5 w-3.5' />
@@ -52,6 +52,7 @@ import {
invalidateUserSubscription,
deleteUserSubscription,
} from '../../api'
import { formatQuota } from '@/lib/format'
import { formatTimestamp } from '../../lib'
import type { PlanRecord, UserSubscriptionRecord } from '../../types'
@@ -301,7 +302,9 @@ export function UserSubscriptionsDialog(props: Props) {
const sub = record.subscription
const total = Number(sub.amount_total || 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>[] {
},
{
id: 'total_amount',
header: t('Received amount'),
header: t('Plan Quota'),
meta: { mobileHidden: true },
cell: ({ row }) => {
const total = Number(row.original.plan.total_amount || 0)
@@ -66,6 +66,7 @@ import {
createWaffoPancakeSubscriptionProduct,
listWaffoPancakeSubscriptionProductOptions,
} from '../api'
import { getCurrencyDisplay, getCurrencyLabel } from '@/lib/currency'
import { getDurationUnitOptions, getResetPeriodOptions } from '../constants'
import {
getPlanFormSchema,
@@ -91,6 +92,9 @@ export function SubscriptionsMutateDrawer({
const { t } = useTranslation()
const isEdit = !!currentRow?.plan?.id
const { triggerRefresh } = useSubscriptions()
const { meta: currencyMeta } = getCurrencyDisplay()
const tokensOnly = currencyMeta.kind === 'tokens'
const currencyLabel = getCurrencyLabel()
const [isSubmitting, setIsSubmitting] = useState(false)
const [groupOptions, setGroupOptions] = useState<string[]>([])
const [creatingPancakeProduct, setCreatingPancakeProduct] = useState(false)
@@ -314,7 +318,7 @@ export function SubscriptionsMutateDrawer({
name='price_amount'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Actual Amount')}</FormLabel>
<FormLabel>{t('Plan Price')}</FormLabel>
<FormControl>
<Input
{...field}
@@ -336,12 +340,22 @@ export function SubscriptionsMutateDrawer({
name='total_amount'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Received amount')}</FormLabel>
<FormLabel>
{t('Quota ({{currency}})', { currency: currencyLabel })}
</FormLabel>
<FormControl>
<Input
{...field}
type='number'
min={0}
step={tokensOnly ? 1 : 0.01}
placeholder={
tokensOnly
? t('Enter quota in tokens')
: t('Enter quota in {{currency}}', {
currency: currencyLabel,
})
}
onChange={(e) =>
field.onChange(parseFloat(e.target.value) || 0)
}
@@ -349,7 +363,7 @@ export function SubscriptionsMutateDrawer({
</FormControl>
<FormDescription>
{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>
<FormMessage />
@@ -398,6 +412,53 @@ export function SubscriptionsMutateDrawer({
)}
/>
<FormField
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'
@@ -423,27 +484,27 @@ export function SubscriptionsMutateDrawer({
/>
</div>
<div className='grid grid-cols-1 gap-3 sm:grid-cols-2'>
<FormField
control={form.control}
name='sort_order'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Sort Order')}</FormLabel>
<FormControl>
<Input
{...field}
type='number'
onChange={(e) =>
field.onChange(parseInt(e.target.value, 10) || 0)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='sort_order'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Sort Order')}</FormLabel>
<FormControl>
<Input
{...field}
type='number'
onChange={(e) =>
field.onChange(parseInt(e.target.value, 10) || 0)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className='flex flex-col gap-3'>
<FormField
control={form.control}
name='enabled'
@@ -479,6 +540,24 @@ export function SubscriptionsMutateDrawer({
</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>
</SideDrawerSection>
@@ -40,9 +40,11 @@ export function getPlanFormSchema(t: TFunction) {
enabled: z.boolean(),
sort_order: z.coerce.number(),
allow_balance_pay: z.boolean(),
allow_wallet_overflow: z.boolean(),
max_purchase_per_user: z.coerce.number().min(0),
total_amount: z.coerce.number().min(0),
upgrade_group: z.string().optional(),
downgrade_group: z.string().optional(),
stripe_price_id: z.string().optional(),
creem_product_id: z.string().optional(),
waffo_pancake_product_id: z.string().optional(),
@@ -63,9 +65,11 @@ export const PLAN_FORM_DEFAULTS: PlanFormValues = {
enabled: true,
sort_order: 0,
allow_balance_pay: true,
allow_wallet_overflow: true,
max_purchase_per_user: 0,
total_amount: 0,
upgrade_group: '',
downgrade_group: '',
stripe_price_id: '',
creem_product_id: '',
waffo_pancake_product_id: '',
@@ -84,9 +88,11 @@ export function planToFormValues(plan: SubscriptionPlan): PlanFormValues {
enabled: plan.enabled !== false,
sort_order: Number(plan.sort_order || 0),
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),
total_amount: quotaUnitsToDollars(Number(plan.total_amount || 0)),
upgrade_group: plan.upgrade_group || '',
downgrade_group: plan.downgrade_group || '',
stripe_price_id: plan.stripe_price_id || '',
creem_product_id: plan.creem_product_id || '',
waffo_pancake_product_id: plan.waffo_pancake_product_id || '',
@@ -110,6 +116,7 @@ export function formValuesToPlanPayload(values: PlanFormValues): PlanPayload {
max_purchase_per_user: Number(values.max_purchase_per_user || 0),
total_amount: parseQuotaFromDollars(Number(values.total_amount || 0)),
upgrade_group: values.upgrade_group || '',
downgrade_group: values.downgrade_group || '',
},
}
}
+2
View File
@@ -36,9 +36,11 @@ export const subscriptionPlanSchema = z.object({
enabled: z.boolean(),
sort_order: z.number(),
allow_balance_pay: z.boolean().optional().default(true),
allow_wallet_overflow: z.boolean().optional().default(true),
max_purchase_per_user: z.number(),
total_amount: z.number(),
upgrade_group: z.string().optional(),
downgrade_group: z.string().optional(),
stripe_price_id: z.string().optional(),
creem_product_id: z.string().optional(),
waffo_pancake_product_id: z.string().optional(),
+7
View File
@@ -138,6 +138,9 @@
"Active models": "Active models",
"active users": "active users",
"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:",
"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 @@
"Allocated Memory": "Allocated Memory",
"Allow accountFilter parameter": "Allow accountFilter parameter",
"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 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",
+7
View File
@@ -138,6 +138,9 @@
"Active models": "Modèles actifs",
"active users": "utilisateurs actifs",
"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 :",
"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 dAnthropic tout en gardant une facturation prévisible.",
@@ -276,6 +279,10 @@
"Allocated Memory": "Mémoire allouée",
"Allow accountFilter parameter": "Autoriser le paramètre accountFilter",
"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 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",
+7
View File
@@ -138,6 +138,9 @@
"Active models": "アクティブなモデル",
"active users": "アクティブユーザー",
"Actual Amount": "実際の金額",
"Plan Price": "プラン価格",
"Plan Quota": "プランのクォータ",
"Total quota included in the plan, usable per billing period. 0 means unlimited.": "プランに含まれる合計クォータ。請求期間ごとに使用可能。0 は無制限を意味します。",
"Actual Model": "実際のモデル",
"Actual Model:": "実際のモデル:",
"Adapt `-thinking` suffix requests to Anthropic native thinking behavior while keeping billing predictable.": "`-thinking` サフィックス付きリクエストを Anthropic ネイティブの思考動作に適配し、課金を予測可能に保ちます。",
@@ -276,6 +279,10 @@
"Allocated Memory": "割り当て済みメモリ",
"Allow accountFilter parameter": "accountFilter パラメータを許可",
"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 clients to query configured ratios via `/api/ratio`.": "クライアントが `/api/ratio` 経由で設定された比率を照会できるようにします。",
"Allow HTTP image requests": "HTTP画像リクエストを許可",
+7
View File
@@ -138,6 +138,9 @@
"Active models": "Активные модели",
"active users": "активных пользователей",
"Actual Amount": "Фактическая сумма",
"Plan Price": "Цена тарифа",
"Plan Quota": "Квота тарифа",
"Total quota included in the plan, usable per billing period. 0 means unlimited.": "Общая квота, включённая в тариф, доступна за каждый расчётный период. 0 означает безлимит.",
"Actual Model": "Фактическая модель",
"Actual Model:": "Фактическая модель:",
"Adapt `-thinking` suffix requests to Anthropic native thinking behavior while keeping billing predictable.": "Адаптировать запросы с суффиксом `-thinking` к собственному режиму размышления Anthropic, сохраняя предсказуемость биллинга.",
@@ -276,6 +279,10 @@
"Allocated Memory": "Выделенная память",
"Allow accountFilter parameter": "Разрешить параметр accountFilter",
"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 clients to query configured ratios via `/api/ratio`.": "Разрешить клиентам запрашивать настроенные соотношения через `/api/ratio`.",
"Allow HTTP image requests": "Разрешить HTTP-запросы изображений",
+7
View File
@@ -138,6 +138,9 @@
"Active models": "Mô hình đang hoạt động",
"active users": "Người dùng tích cực",
"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ế:",
"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 @@
"Allocated Memory": "Bộ nhớ đã cấp phát",
"Allow accountFilter parameter": "Cho phép tham số accountFilter",
"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 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",
+7
View File
@@ -138,6 +138,9 @@
"Active models": "活跃模型",
"active users": "活跃用户",
"Actual Amount": "实付金额",
"Plan Price": "套餐价格",
"Plan Quota": "套餐额度",
"Total quota included in the plan, usable per billing period. 0 means unlimited.": "套餐包含的总额度,每个计费周期可用;0 表示不限量",
"Actual Model": "实际模型",
"Actual Model:": "实际模型:",
"Adapt `-thinking` suffix requests to Anthropic native thinking behavior while keeping billing predictable.": "将带 `-thinking` 后缀的请求适配为 Anthropic 原生思考请求,并保持计费可预测。",
@@ -276,6 +279,10 @@
"Allocated Memory": "已分配内存",
"Allow accountFilter parameter": "允许 accountFilter 参数",
"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 clients to query configured ratios via `/api/ratio`.": "允许客户端通过 `/api/ratio` 查询配置的比例。",
"Allow HTTP image requests": "允许 HTTP 图像请求",
+7
View File
@@ -350,6 +350,10 @@ export const STATIC_I18N_KEYS = [
'Priority',
'Payment Channel',
'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',
'Update plan info',
'Create new subscription plan',
@@ -361,6 +365,9 @@ export const STATIC_I18N_KEYS = [
'Plan Subtitle',
'e.g. Suitable for light usage',
'Actual Amount',
'Plan Price',
'Plan Quota',
'Total quota included in the plan, usable per billing period. 0 means unlimited.',
'Total Quota',
'0 means unlimited',
'Sort Order',