feat: add subscription redemption support to redemption codes

Redemption codes can now redeem either quota (existing) or a subscription
plan (new). The Redemption model gains Type (1=quota, 2=subscription) and
PlanId fields. When Type=2, Redeem() calls CreateUserSubscriptionFromPlanTx
to activate the plan, reusing all existing subscription logic (purchase
limits, group upgrade, quota reset, expiry). Old codes default to Type=0
which behaves identically to Type=1 (quota).
This commit is contained in:
zizi 2026-05-19 01:10:14 +08:00
parent e6bced5dc2
commit 0a0616a370
15 changed files with 284 additions and 41 deletions

View File

@ -248,6 +248,11 @@ const (
RedemptionCodeStatusUsed = 3 // also don't use 0 RedemptionCodeStatusUsed = 3 // also don't use 0
) )
const (
RedemptionTypeQuota = 1 // 兑换额度
RedemptionTypeSubscription = 2 // 兑换订阅
)
const ( const (
ChannelStatusUnknown = 0 ChannelStatusUnknown = 0
ChannelStatusEnabled = 1 // don't use 0, 0 is the default value! ChannelStatusEnabled = 1 // don't use 0, 0 is the default value!

View File

@ -83,6 +83,22 @@ func AddRedemption(c *gin.Context) {
common.ApiErrorI18n(c, i18n.MsgRedemptionCountMax) common.ApiErrorI18n(c, i18n.MsgRedemptionCountMax)
return return
} }
// 校验兑换类型
if redemption.Type != 0 && redemption.Type != common.RedemptionTypeQuota && redemption.Type != common.RedemptionTypeSubscription {
common.ApiErrorI18n(c, i18n.MsgRedemptionTypeInvalid)
return
}
if redemption.Type == common.RedemptionTypeSubscription {
if redemption.PlanId <= 0 {
common.ApiErrorI18n(c, i18n.MsgRedemptionPlanRequired)
return
}
plan, err := model.GetSubscriptionPlanById(redemption.PlanId)
if err != nil || plan == nil {
common.ApiErrorI18n(c, i18n.MsgRedemptionPlanNotFound)
return
}
}
if valid, msg := validateExpiredTime(c, redemption.ExpiredTime); !valid { if valid, msg := validateExpiredTime(c, redemption.ExpiredTime); !valid {
c.JSON(http.StatusOK, gin.H{"success": false, "message": msg}) c.JSON(http.StatusOK, gin.H{"success": false, "message": msg})
return return
@ -94,8 +110,10 @@ func AddRedemption(c *gin.Context) {
UserId: c.GetInt("id"), UserId: c.GetInt("id"),
Name: redemption.Name, Name: redemption.Name,
Key: key, Key: key,
CreatedTime: common.GetTimestamp(), Type: redemption.Type,
Quota: redemption.Quota, Quota: redemption.Quota,
PlanId: redemption.PlanId,
CreatedTime: common.GetTimestamp(),
ExpiredTime: redemption.ExpiredTime, ExpiredTime: redemption.ExpiredTime,
} }
err = cleanRedemption.Insert() err = cleanRedemption.Insert()
@ -150,10 +168,28 @@ func UpdateRedemption(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": false, "message": msg}) c.JSON(http.StatusOK, gin.H{"success": false, "message": msg})
return return
} }
// 校验兑换类型
if redemption.Type != 0 && redemption.Type != common.RedemptionTypeQuota && redemption.Type != common.RedemptionTypeSubscription {
common.ApiErrorI18n(c, i18n.MsgRedemptionTypeInvalid)
return
}
if redemption.Type == common.RedemptionTypeSubscription {
if redemption.PlanId <= 0 {
common.ApiErrorI18n(c, i18n.MsgRedemptionPlanRequired)
return
}
plan, err := model.GetSubscriptionPlanById(redemption.PlanId)
if err != nil || plan == nil {
common.ApiErrorI18n(c, i18n.MsgRedemptionPlanNotFound)
return
}
}
// If you add more fields, please also update redemption.Update() // If you add more fields, please also update redemption.Update()
cleanRedemption.Name = redemption.Name cleanRedemption.Name = redemption.Name
cleanRedemption.Quota = redemption.Quota cleanRedemption.Quota = redemption.Quota
cleanRedemption.ExpiredTime = redemption.ExpiredTime cleanRedemption.ExpiredTime = redemption.ExpiredTime
cleanRedemption.Type = redemption.Type
cleanRedemption.PlanId = redemption.PlanId
} }
if statusOnly != "" { if statusOnly != "" {
cleanRedemption.Status = redemption.Status cleanRedemption.Status = redemption.Status

View File

@ -1104,7 +1104,7 @@ func TopUp(c *gin.Context) {
common.ApiError(c, err) common.ApiError(c, err)
return return
} }
quota, err := model.Redeem(req.Key, id) result, err := model.Redeem(req.Key, id)
if err != nil { if err != nil {
if errors.Is(err, model.ErrRedeemFailed) { if errors.Is(err, model.ErrRedeemFailed) {
common.ApiErrorI18n(c, i18n.MsgRedeemFailed) common.ApiErrorI18n(c, i18n.MsgRedeemFailed)
@ -1116,7 +1116,7 @@ func TopUp(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
"message": "", "message": "",
"data": quota, "data": result,
}) })
} }

View File

@ -69,6 +69,9 @@ const (
MsgRedemptionFailed = "redemption.failed" MsgRedemptionFailed = "redemption.failed"
MsgRedemptionNotProvided = "redemption.not_provided" MsgRedemptionNotProvided = "redemption.not_provided"
MsgRedemptionExpireTimeInvalid = "redemption.expire_time_invalid" MsgRedemptionExpireTimeInvalid = "redemption.expire_time_invalid"
MsgRedemptionTypeInvalid = "redemption.type_invalid"
MsgRedemptionPlanRequired = "redemption.plan_required"
MsgRedemptionPlanNotFound = "redemption.plan_not_found"
) )
// User related messages // User related messages

View File

@ -59,6 +59,9 @@ redemption.expired: "This redemption code has expired"
redemption.failed: "Redemption failed, please try again later" redemption.failed: "Redemption failed, please try again later"
redemption.not_provided: "Redemption code not provided" redemption.not_provided: "Redemption code not provided"
redemption.expire_time_invalid: "Expiration time cannot be earlier than current time" redemption.expire_time_invalid: "Expiration time cannot be earlier than current time"
redemption.type_invalid: "Invalid redemption code type"
redemption.plan_required: "Subscription redemption code must specify a plan"
redemption.plan_not_found: "The subscription plan associated with this redemption code does not exist"
# User messages # User messages
user.password_login_disabled: "Password login has been disabled by administrator" user.password_login_disabled: "Password login has been disabled by administrator"

View File

@ -60,6 +60,9 @@ redemption.expired: "该兑换码已过期"
redemption.failed: "兑换失败,请稍后重试" redemption.failed: "兑换失败,请稍后重试"
redemption.not_provided: "未提供兑换码" redemption.not_provided: "未提供兑换码"
redemption.expire_time_invalid: "过期时间不能早于当前时间" redemption.expire_time_invalid: "过期时间不能早于当前时间"
redemption.type_invalid: "无效的兑换码类型"
redemption.plan_required: "订阅类型兑换码必须指定套餐"
redemption.plan_not_found: "兑换码关联的订阅套餐不存在"
# User messages # User messages
user.password_login_disabled: "管理员关闭了密码登录" user.password_login_disabled: "管理员关闭了密码登录"

View File

@ -60,6 +60,9 @@ redemption.expired: "該兌換碼已過期"
redemption.failed: "兌換失敗,請稍後重試" redemption.failed: "兌換失敗,請稍後重試"
redemption.not_provided: "未提供兌換碼" redemption.not_provided: "未提供兌換碼"
redemption.expire_time_invalid: "過期時間不能早於當前時間" redemption.expire_time_invalid: "過期時間不能早於當前時間"
redemption.type_invalid: "無效的兌換碼類型"
redemption.plan_required: "訂閱類型兌換碼必須指定套餐"
redemption.plan_not_found: "兌換碼關聯的訂閱套餐不存在"
# User messages # User messages
user.password_login_disabled: "管理員關閉了密碼登錄" user.password_login_disabled: "管理員關閉了密碼登錄"

View File

@ -17,7 +17,9 @@ type Redemption struct {
Key string `json:"key" gorm:"type:char(32);uniqueIndex"` Key string `json:"key" gorm:"type:char(32);uniqueIndex"`
Status int `json:"status" gorm:"default:1"` Status int `json:"status" gorm:"default:1"`
Name string `json:"name" gorm:"index"` Name string `json:"name" gorm:"index"`
Type int `json:"type" gorm:"default:1"` // 1=额度 2=订阅
Quota int `json:"quota" gorm:"default:100"` Quota int `json:"quota" gorm:"default:100"`
PlanId int `json:"plan_id" gorm:"default:0"` // Type=2 时关联的订阅套餐
CreatedTime int64 `json:"created_time" gorm:"bigint"` CreatedTime int64 `json:"created_time" gorm:"bigint"`
RedeemedTime int64 `json:"redeemed_time" gorm:"bigint"` RedeemedTime int64 `json:"redeemed_time" gorm:"bigint"`
Count int `json:"count" gorm:"-:all"` // only for api request Count int `json:"count" gorm:"-:all"` // only for api request
@ -112,12 +114,19 @@ func GetRedemptionById(id int) (*Redemption, error) {
return &redemption, err return &redemption, err
} }
func Redeem(key string, userId int) (quota int, err error) { // RedeemResult 兑换结果
type RedeemResult struct {
Type int // 1=额度 2=订阅
Quota int // Type=1 时返回兑换的额度
PlanId int // Type=2 时返回套餐ID
}
func Redeem(key string, userId int) (*RedeemResult, error) {
if key == "" { if key == "" {
return 0, errors.New("未提供兑换码") return nil, errors.New("未提供兑换码")
} }
if userId == 0 { if userId == 0 {
return 0, errors.New("无效的 user id") return nil, errors.New("无效的 user id")
} }
redemption := &Redemption{} redemption := &Redemption{}
@ -126,7 +135,7 @@ func Redeem(key string, userId int) (quota int, err error) {
keyCol = `"key"` keyCol = `"key"`
} }
common.RandomSleep() common.RandomSleep()
err = DB.Transaction(func(tx *gorm.DB) error { err := DB.Transaction(func(tx *gorm.DB) error {
err := tx.Set("gorm:query_option", "FOR UPDATE").Where(keyCol+" = ?", key).First(redemption).Error err := tx.Set("gorm:query_option", "FOR UPDATE").Where(keyCol+" = ?", key).First(redemption).Error
if err != nil { if err != nil {
return errors.New("无效的兑换码") return errors.New("无效的兑换码")
@ -137,10 +146,29 @@ func Redeem(key string, userId int) (quota int, err error) {
if redemption.ExpiredTime != 0 && redemption.ExpiredTime < common.GetTimestamp() { if redemption.ExpiredTime != 0 && redemption.ExpiredTime < common.GetTimestamp() {
return errors.New("该兑换码已过期") return errors.New("该兑换码已过期")
} }
err = tx.Model(&User{}).Where("id = ?", userId).Update("quota", gorm.Expr("quota + ?", redemption.Quota)).Error
if err != nil { switch redemption.Type {
return err case common.RedemptionTypeQuota, 0:
err = tx.Model(&User{}).Where("id = ?", userId).Update("quota", gorm.Expr("quota + ?", redemption.Quota)).Error
if err != nil {
return err
}
case common.RedemptionTypeSubscription:
plan, planErr := GetSubscriptionPlanById(redemption.PlanId)
if planErr != nil {
return errors.New("兑换码关联的订阅套餐不存在")
}
if !plan.Enabled {
return errors.New("兑换码关联的订阅套餐已禁用")
}
_, subErr := CreateUserSubscriptionFromPlanTx(tx, userId, plan, "redemption")
if subErr != nil {
return subErr
}
default:
return errors.New("无效的兑换码类型")
} }
redemption.RedeemedTime = common.GetTimestamp() redemption.RedeemedTime = common.GetTimestamp()
redemption.Status = common.RedemptionCodeStatusUsed redemption.Status = common.RedemptionCodeStatusUsed
redemption.UsedUserId = userId redemption.UsedUserId = userId
@ -149,10 +177,19 @@ func Redeem(key string, userId int) (quota int, err error) {
}) })
if err != nil { if err != nil {
common.SysError("redemption failed: " + err.Error()) common.SysError("redemption failed: " + err.Error())
return 0, ErrRedeemFailed return nil, ErrRedeemFailed
} }
RecordLog(userId, LogTypeTopup, fmt.Sprintf("通过兑换码充值 %s兑换码ID %d", logger.LogQuota(redemption.Quota), redemption.Id))
return redemption.Quota, nil result := &RedeemResult{Type: redemption.Type}
switch redemption.Type {
case common.RedemptionTypeSubscription:
result.PlanId = redemption.PlanId
RecordLog(userId, LogTypeTopup, fmt.Sprintf("通过兑换码兑换订阅套餐套餐ID %d兑换码ID %d", redemption.PlanId, redemption.Id))
default:
result.Quota = redemption.Quota
RecordLog(userId, LogTypeTopup, fmt.Sprintf("通过兑换码充值 %s兑换码ID %d", logger.LogQuota(redemption.Quota), redemption.Id))
}
return result, nil
} }
func (redemption *Redemption) Insert() error { func (redemption *Redemption) Insert() error {
@ -169,7 +206,7 @@ func (redemption *Redemption) SelectUpdate() error {
// Update Make sure your token's fields is completed, because this will update non-zero values // Update Make sure your token's fields is completed, because this will update non-zero values
func (redemption *Redemption) Update() error { func (redemption *Redemption) Update() error {
var err error var err error
err = DB.Model(redemption).Select("name", "status", "quota", "redeemed_time", "expired_time").Updates(redemption).Error err = DB.Model(redemption).Select("name", "status", "quota", "redeemed_time", "expired_time", "type", "plan_id").Updates(redemption).Error
return err return err
} }

View File

@ -28,7 +28,7 @@ import {
import { DataTableColumnHeader } from '@/components/data-table' import { DataTableColumnHeader } from '@/components/data-table'
import { MaskedValueDisplay } from '@/components/masked-value-display' import { MaskedValueDisplay } from '@/components/masked-value-display'
import { StatusBadge } from '@/components/status-badge' import { StatusBadge } from '@/components/status-badge'
import { REDEMPTION_FILTER_EXPIRED, REDEMPTION_STATUSES } from '../constants' import { REDEMPTION_FILTER_EXPIRED, REDEMPTION_STATUSES, REDEMPTION_TYPE } from '../constants'
import { isRedemptionExpired, isTimestampExpired } from '../lib' import { isRedemptionExpired, isTimestampExpired } from '../lib'
import { type Redemption } from '../types' import { type Redemption } from '../types'
import { DataTableRowActions } from './data-table-row-actions' import { DataTableRowActions } from './data-table-row-actions'
@ -135,6 +135,36 @@ export function useRedemptionsColumns(): ColumnDef<Redemption>[] {
return value.includes(String(statusValue)) return value.includes(String(statusValue))
}, },
}, },
{
accessorKey: 'type',
meta: { label: t('Type') },
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Type')} />
),
cell: ({ row }) => {
const type = row.getValue('type') as number
if (type === REDEMPTION_TYPE.SUBSCRIPTION) {
return (
<StatusBadge
label={t('Subscription')}
variant='info'
copyable={false}
/>
)
}
return (
<StatusBadge
label={t('Quota')}
variant='neutral'
copyable={false}
/>
)
},
filterFn: (row, id, value) => {
const typeValue = row.getValue(id) as number
return value.includes(String(typeValue || 1))
},
},
{ {
id: 'code', id: 'code',
accessorKey: 'key', accessorKey: 'key',

View File

@ -34,6 +34,13 @@ import {
FormMessage, FormMessage,
} from '@/components/ui/form' } from '@/components/ui/form'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { import {
Sheet, Sheet,
SheetClose, SheetClose,
@ -44,8 +51,9 @@ import {
SheetTitle, SheetTitle,
} from '@/components/ui/sheet' } from '@/components/ui/sheet'
import { DateTimePicker } from '@/components/datetime-picker' import { DateTimePicker } from '@/components/datetime-picker'
import { getAdminPlans } from '@/features/subscriptions/api'
import { createRedemption, updateRedemption, getRedemption } from '../api' import { createRedemption, updateRedemption, getRedemption } from '../api'
import { SUCCESS_MESSAGES } from '../constants' import { SUCCESS_MESSAGES, REDEMPTION_TYPE } from '../constants'
import { import {
getRedemptionFormSchema, getRedemptionFormSchema,
type RedemptionFormValues, type RedemptionFormValues,
@ -71,12 +79,27 @@ export function RedemptionsMutateDrawer({
const isUpdate = !!currentRow const isUpdate = !!currentRow
const { triggerRefresh } = useRedemptions() const { triggerRefresh } = useRedemptions()
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
const [plans, setPlans] = useState<{ id: number; title: string }[]>([])
const form = useForm<RedemptionFormValues>({ const form = useForm<RedemptionFormValues>({
resolver: zodResolver(getRedemptionFormSchema(t)), resolver: zodResolver(getRedemptionFormSchema(t)),
defaultValues: REDEMPTION_FORM_DEFAULT_VALUES, defaultValues: REDEMPTION_FORM_DEFAULT_VALUES,
}) })
const watchType = form.watch('type')
const isSubscription = watchType === REDEMPTION_TYPE.SUBSCRIPTION
// Load subscription plans when drawer opens
useEffect(() => {
if (open) {
getAdminPlans().then((res) => {
if (res.success && res.data) {
setPlans(res.data.map((p) => ({ id: p.id, title: p.title })))
}
})
}
}, [open])
// Load existing data when updating // Load existing data when updating
useEffect(() => { useEffect(() => {
if (open && isUpdate && currentRow) { if (open && isUpdate && currentRow) {
@ -192,33 +215,102 @@ export function RedemptionsMutateDrawer({
<FormField <FormField
control={form.control} control={form.control}
name='quota_dollars' name='type'
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>{quotaLabel}</FormLabel> <FormLabel>{t('Redemption Type')}</FormLabel>
<FormControl> <Select
<Input value={String(field.value)}
{...field} onValueChange={(v) => field.onChange(Number(v))}
type='number' >
step={tokensOnly ? 1 : 0.01} <FormControl>
placeholder={quotaPlaceholder} <SelectTrigger>
onChange={(e) => <SelectValue placeholder={t('Select type')} />
field.onChange(parseFloat(e.target.value) || 0) </SelectTrigger>
} </FormControl>
/> <SelectContent>
</FormControl> <SelectItem value={String(REDEMPTION_TYPE.QUOTA)}>
{t('Quota')}
</SelectItem>
<SelectItem value={String(REDEMPTION_TYPE.SUBSCRIPTION)}>
{t('Subscription')}
</SelectItem>
</SelectContent>
</Select>
<FormDescription> <FormDescription>
{tokensOnly {isSubscription
? t('Enter the quota amount in tokens') ? t('Redeem to activate a subscription plan')
: t('Enter the quota amount in {{currency}}', { : t('Redeem to add quota to wallet')}
currency: currencyLabel,
})}
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
{!isSubscription && (
<FormField
control={form.control}
name='quota_dollars'
render={({ field }) => (
<FormItem>
<FormLabel>{quotaLabel}</FormLabel>
<FormControl>
<Input
{...field}
type='number'
step={tokensOnly ? 1 : 0.01}
placeholder={quotaPlaceholder}
onChange={(e) =>
field.onChange(parseFloat(e.target.value) || 0)
}
/>
</FormControl>
<FormDescription>
{tokensOnly
? t('Enter the quota amount in tokens')
: t('Enter the quota amount in {{currency}}', {
currency: currencyLabel,
})}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
{isSubscription && (
<FormField
control={form.control}
name='plan_id'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Subscription Plan')}</FormLabel>
<Select
value={String(field.value)}
onValueChange={(v) => field.onChange(Number(v))}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t('Select a plan')} />
</SelectTrigger>
</FormControl>
<SelectContent>
{plans.map((plan) => (
<SelectItem key={plan.id} value={String(plan.id)}>
{plan.title}
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>
{t('The subscription plan to activate when redeemed')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField <FormField
control={form.control} control={form.control}
name='expired_time' name='expired_time'

View File

@ -29,6 +29,11 @@ export const REDEMPTION_STATUS = {
USED: 3, USED: 3,
} as const } as const
export const REDEMPTION_TYPE = {
QUOTA: 1,
SUBSCRIPTION: 2,
} as const
export const REDEMPTION_STATUS_VALUES = Object.values(REDEMPTION_STATUS).map( export const REDEMPTION_STATUS_VALUES = Object.values(REDEMPTION_STATUS).map(
(value) => String(value) (value) => String(value)
) as `${number}`[] ) as `${number}`[]

View File

@ -36,7 +36,9 @@ export function getRedemptionFormSchema(t: TFunction) {
.string() .string()
.min(REDEMPTION_VALIDATION.NAME_MIN_LENGTH, msg.NAME_LENGTH_INVALID) .min(REDEMPTION_VALIDATION.NAME_MIN_LENGTH, msg.NAME_LENGTH_INVALID)
.max(REDEMPTION_VALIDATION.NAME_MAX_LENGTH, msg.NAME_LENGTH_INVALID), .max(REDEMPTION_VALIDATION.NAME_MAX_LENGTH, msg.NAME_LENGTH_INVALID),
type: z.number().default(1),
quota_dollars: z.number().min(0, t('Quota must be a positive number')), quota_dollars: z.number().min(0, t('Quota must be a positive number')),
plan_id: z.number().default(0),
expired_time: z.date().optional(), expired_time: z.date().optional(),
count: z count: z
.number() .number()
@ -48,7 +50,9 @@ export function getRedemptionFormSchema(t: TFunction) {
export type RedemptionFormValues = { export type RedemptionFormValues = {
name: string name: string
type: number
quota_dollars: number quota_dollars: number
plan_id: number
expired_time?: Date expired_time?: Date
count?: number count?: number
} }
@ -59,7 +63,9 @@ export type RedemptionFormValues = {
export const REDEMPTION_FORM_DEFAULT_VALUES: RedemptionFormValues = { export const REDEMPTION_FORM_DEFAULT_VALUES: RedemptionFormValues = {
name: '', name: '',
type: 1,
quota_dollars: 10, quota_dollars: 10,
plan_id: 0,
expired_time: undefined, expired_time: undefined,
count: 1, count: 1,
} }
@ -76,7 +82,9 @@ export function transformFormDataToPayload(
): RedemptionFormData { ): RedemptionFormData {
return { return {
name: data.name, name: data.name,
type: data.type,
quota: parseQuotaFromDollars(data.quota_dollars), quota: parseQuotaFromDollars(data.quota_dollars),
plan_id: data.plan_id,
expired_time: data.expired_time expired_time: data.expired_time
? Math.floor(data.expired_time.getTime() / 1000) ? Math.floor(data.expired_time.getTime() / 1000)
: 0, : 0,
@ -92,7 +100,9 @@ export function transformRedemptionToFormDefaults(
): RedemptionFormValues { ): RedemptionFormValues {
return { return {
name: redemption.name, name: redemption.name,
type: redemption.type || 1,
quota_dollars: quotaUnitsToDollars(redemption.quota), quota_dollars: quotaUnitsToDollars(redemption.quota),
plan_id: redemption.plan_id || 0,
expired_time: expired_time:
redemption.expired_time > 0 redemption.expired_time > 0
? new Date(redemption.expired_time * 1000) ? new Date(redemption.expired_time * 1000)

View File

@ -28,7 +28,9 @@ export const redemptionSchema = z.object({
name: z.string(), name: z.string(),
key: z.string(), key: z.string(),
status: z.number(), // 1: enabled, 2: disabled, 3: used status: z.number(), // 1: enabled, 2: disabled, 3: used
type: z.number(), // 1: quota, 2: subscription
quota: z.number(), quota: z.number(),
plan_id: z.number(), // only when type=2
created_time: z.number(), created_time: z.number(),
redeemed_time: z.number(), redeemed_time: z.number(),
expired_time: z.number(), // 0 for never expires expired_time: z.number(), // 0 for never expires
@ -72,7 +74,9 @@ export interface SearchRedemptionsParams {
export interface RedemptionFormData { export interface RedemptionFormData {
id?: number id?: number
name: string name: string
type: number // 1: quota, 2: subscription
quota: number quota: number
plan_id: number // only when type=2
expired_time: number expired_time: number
count?: number // Only for create count?: number // Only for create
status?: number // Only for status update status?: number // Only for status update

View File

@ -23,6 +23,8 @@ import { getSelf } from '@/lib/api'
import { formatQuota } from '@/lib/format' import { formatQuota } from '@/lib/format'
import { redeemTopupCode } from '../api' import { redeemTopupCode } from '../api'
const REDEMPTION_TYPE_SUBSCRIPTION = 2
// ============================================================================ // ============================================================================
// Redemption Hook // Redemption Hook
// ============================================================================ // ============================================================================
@ -41,12 +43,18 @@ export function useRedemption() {
const response = await redeemTopupCode({ key: code }) const response = await redeemTopupCode({ key: code })
if (response.success && response.data) { if (response.success && response.data) {
const quotaAdded = response.data const result = response.data
toast.success( if (result.type === REDEMPTION_TYPE_SUBSCRIPTION) {
i18next.t('Redemption successful! Added: {{quota}}', { toast.success(
quota: formatQuota(quotaAdded), i18next.t('Redemption successful! Subscription plan activated')
}) )
) } else {
toast.success(
i18next.t('Redemption successful! Added: {{quota}}', {
quota: formatQuota(result.quota),
})
)
}
await getSelf() await getSelf()
return true return true
} }

View File

@ -33,7 +33,11 @@ export interface ApiResponse<T = unknown> {
* Standard API response types * Standard API response types
*/ */
export type TopupInfoResponse = ApiResponse<TopupInfo> export type TopupInfoResponse = ApiResponse<TopupInfo>
export type RedemptionResponse = ApiResponse<number> export type RedemptionResponse = ApiResponse<{
type: number // 1: quota, 2: subscription
quota: number // type=1
plan_id: number // type=2
}>
export type AmountResponse = ApiResponse<string> export type AmountResponse = ApiResponse<string>
export type PaymentResponse = ApiResponse<Record<string, unknown>> & { export type PaymentResponse = ApiResponse<Record<string, unknown>> & {
url?: string url?: string