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:
parent
e6bced5dc2
commit
0a0616a370
@ -248,6 +248,11 @@ const (
|
||||
RedemptionCodeStatusUsed = 3 // also don't use 0
|
||||
)
|
||||
|
||||
const (
|
||||
RedemptionTypeQuota = 1 // 兑换额度
|
||||
RedemptionTypeSubscription = 2 // 兑换订阅
|
||||
)
|
||||
|
||||
const (
|
||||
ChannelStatusUnknown = 0
|
||||
ChannelStatusEnabled = 1 // don't use 0, 0 is the default value!
|
||||
|
||||
@ -83,6 +83,22 @@ func AddRedemption(c *gin.Context) {
|
||||
common.ApiErrorI18n(c, i18n.MsgRedemptionCountMax)
|
||||
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 {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": msg})
|
||||
return
|
||||
@ -94,8 +110,10 @@ func AddRedemption(c *gin.Context) {
|
||||
UserId: c.GetInt("id"),
|
||||
Name: redemption.Name,
|
||||
Key: key,
|
||||
CreatedTime: common.GetTimestamp(),
|
||||
Type: redemption.Type,
|
||||
Quota: redemption.Quota,
|
||||
PlanId: redemption.PlanId,
|
||||
CreatedTime: common.GetTimestamp(),
|
||||
ExpiredTime: redemption.ExpiredTime,
|
||||
}
|
||||
err = cleanRedemption.Insert()
|
||||
@ -150,10 +168,28 @@ func UpdateRedemption(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": msg})
|
||||
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()
|
||||
cleanRedemption.Name = redemption.Name
|
||||
cleanRedemption.Quota = redemption.Quota
|
||||
cleanRedemption.ExpiredTime = redemption.ExpiredTime
|
||||
cleanRedemption.Type = redemption.Type
|
||||
cleanRedemption.PlanId = redemption.PlanId
|
||||
}
|
||||
if statusOnly != "" {
|
||||
cleanRedemption.Status = redemption.Status
|
||||
|
||||
@ -1104,7 +1104,7 @@ func TopUp(c *gin.Context) {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
quota, err := model.Redeem(req.Key, id)
|
||||
result, err := model.Redeem(req.Key, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, model.ErrRedeemFailed) {
|
||||
common.ApiErrorI18n(c, i18n.MsgRedeemFailed)
|
||||
@ -1116,7 +1116,7 @@ func TopUp(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": quota,
|
||||
"data": result,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -69,6 +69,9 @@ const (
|
||||
MsgRedemptionFailed = "redemption.failed"
|
||||
MsgRedemptionNotProvided = "redemption.not_provided"
|
||||
MsgRedemptionExpireTimeInvalid = "redemption.expire_time_invalid"
|
||||
MsgRedemptionTypeInvalid = "redemption.type_invalid"
|
||||
MsgRedemptionPlanRequired = "redemption.plan_required"
|
||||
MsgRedemptionPlanNotFound = "redemption.plan_not_found"
|
||||
)
|
||||
|
||||
// User related messages
|
||||
|
||||
@ -59,6 +59,9 @@ redemption.expired: "This redemption code has expired"
|
||||
redemption.failed: "Redemption failed, please try again later"
|
||||
redemption.not_provided: "Redemption code not provided"
|
||||
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.password_login_disabled: "Password login has been disabled by administrator"
|
||||
|
||||
@ -60,6 +60,9 @@ redemption.expired: "该兑换码已过期"
|
||||
redemption.failed: "兑换失败,请稍后重试"
|
||||
redemption.not_provided: "未提供兑换码"
|
||||
redemption.expire_time_invalid: "过期时间不能早于当前时间"
|
||||
redemption.type_invalid: "无效的兑换码类型"
|
||||
redemption.plan_required: "订阅类型兑换码必须指定套餐"
|
||||
redemption.plan_not_found: "兑换码关联的订阅套餐不存在"
|
||||
|
||||
# User messages
|
||||
user.password_login_disabled: "管理员关闭了密码登录"
|
||||
|
||||
@ -60,6 +60,9 @@ redemption.expired: "該兌換碼已過期"
|
||||
redemption.failed: "兌換失敗,請稍後重試"
|
||||
redemption.not_provided: "未提供兌換碼"
|
||||
redemption.expire_time_invalid: "過期時間不能早於當前時間"
|
||||
redemption.type_invalid: "無效的兌換碼類型"
|
||||
redemption.plan_required: "訂閱類型兌換碼必須指定套餐"
|
||||
redemption.plan_not_found: "兌換碼關聯的訂閱套餐不存在"
|
||||
|
||||
# User messages
|
||||
user.password_login_disabled: "管理員關閉了密碼登錄"
|
||||
|
||||
@ -17,7 +17,9 @@ type Redemption struct {
|
||||
Key string `json:"key" gorm:"type:char(32);uniqueIndex"`
|
||||
Status int `json:"status" gorm:"default:1"`
|
||||
Name string `json:"name" gorm:"index"`
|
||||
Type int `json:"type" gorm:"default:1"` // 1=额度 2=订阅
|
||||
Quota int `json:"quota" gorm:"default:100"`
|
||||
PlanId int `json:"plan_id" gorm:"default:0"` // Type=2 时关联的订阅套餐
|
||||
CreatedTime int64 `json:"created_time" gorm:"bigint"`
|
||||
RedeemedTime int64 `json:"redeemed_time" gorm:"bigint"`
|
||||
Count int `json:"count" gorm:"-:all"` // only for api request
|
||||
@ -112,12 +114,19 @@ func GetRedemptionById(id int) (*Redemption, error) {
|
||||
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 == "" {
|
||||
return 0, errors.New("未提供兑换码")
|
||||
return nil, errors.New("未提供兑换码")
|
||||
}
|
||||
if userId == 0 {
|
||||
return 0, errors.New("无效的 user id")
|
||||
return nil, errors.New("无效的 user id")
|
||||
}
|
||||
redemption := &Redemption{}
|
||||
|
||||
@ -126,7 +135,7 @@ func Redeem(key string, userId int) (quota int, err error) {
|
||||
keyCol = `"key"`
|
||||
}
|
||||
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
|
||||
if err != nil {
|
||||
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() {
|
||||
return errors.New("该兑换码已过期")
|
||||
}
|
||||
err = tx.Model(&User{}).Where("id = ?", userId).Update("quota", gorm.Expr("quota + ?", redemption.Quota)).Error
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
switch redemption.Type {
|
||||
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.Status = common.RedemptionCodeStatusUsed
|
||||
redemption.UsedUserId = userId
|
||||
@ -149,10 +177,19 @@ func Redeem(key string, userId int) (quota int, err error) {
|
||||
})
|
||||
if err != nil {
|
||||
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 {
|
||||
@ -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
|
||||
func (redemption *Redemption) Update() 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
|
||||
}
|
||||
|
||||
|
||||
@ -28,7 +28,7 @@ import {
|
||||
import { DataTableColumnHeader } from '@/components/data-table'
|
||||
import { MaskedValueDisplay } from '@/components/masked-value-display'
|
||||
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 { type Redemption } from '../types'
|
||||
import { DataTableRowActions } from './data-table-row-actions'
|
||||
@ -135,6 +135,36 @@ export function useRedemptionsColumns(): ColumnDef<Redemption>[] {
|
||||
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',
|
||||
accessorKey: 'key',
|
||||
|
||||
@ -34,6 +34,13 @@ import {
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Sheet,
|
||||
SheetClose,
|
||||
@ -44,8 +51,9 @@ import {
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet'
|
||||
import { DateTimePicker } from '@/components/datetime-picker'
|
||||
import { getAdminPlans } from '@/features/subscriptions/api'
|
||||
import { createRedemption, updateRedemption, getRedemption } from '../api'
|
||||
import { SUCCESS_MESSAGES } from '../constants'
|
||||
import { SUCCESS_MESSAGES, REDEMPTION_TYPE } from '../constants'
|
||||
import {
|
||||
getRedemptionFormSchema,
|
||||
type RedemptionFormValues,
|
||||
@ -71,12 +79,27 @@ export function RedemptionsMutateDrawer({
|
||||
const isUpdate = !!currentRow
|
||||
const { triggerRefresh } = useRedemptions()
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [plans, setPlans] = useState<{ id: number; title: string }[]>([])
|
||||
|
||||
const form = useForm<RedemptionFormValues>({
|
||||
resolver: zodResolver(getRedemptionFormSchema(t)),
|
||||
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
|
||||
useEffect(() => {
|
||||
if (open && isUpdate && currentRow) {
|
||||
@ -192,33 +215,102 @@ export function RedemptionsMutateDrawer({
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='quota_dollars'
|
||||
name='type'
|
||||
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>
|
||||
<FormLabel>{t('Redemption Type')}</FormLabel>
|
||||
<Select
|
||||
value={String(field.value)}
|
||||
onValueChange={(v) => field.onChange(Number(v))}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('Select type')} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value={String(REDEMPTION_TYPE.QUOTA)}>
|
||||
{t('Quota')}
|
||||
</SelectItem>
|
||||
<SelectItem value={String(REDEMPTION_TYPE.SUBSCRIPTION)}>
|
||||
{t('Subscription')}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
{tokensOnly
|
||||
? t('Enter the quota amount in tokens')
|
||||
: t('Enter the quota amount in {{currency}}', {
|
||||
currency: currencyLabel,
|
||||
})}
|
||||
{isSubscription
|
||||
? t('Redeem to activate a subscription plan')
|
||||
: t('Redeem to add quota to wallet')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</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
|
||||
control={form.control}
|
||||
name='expired_time'
|
||||
|
||||
@ -29,6 +29,11 @@ export const REDEMPTION_STATUS = {
|
||||
USED: 3,
|
||||
} as const
|
||||
|
||||
export const REDEMPTION_TYPE = {
|
||||
QUOTA: 1,
|
||||
SUBSCRIPTION: 2,
|
||||
} as const
|
||||
|
||||
export const REDEMPTION_STATUS_VALUES = Object.values(REDEMPTION_STATUS).map(
|
||||
(value) => String(value)
|
||||
) as `${number}`[]
|
||||
|
||||
@ -36,7 +36,9 @@ export function getRedemptionFormSchema(t: TFunction) {
|
||||
.string()
|
||||
.min(REDEMPTION_VALIDATION.NAME_MIN_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')),
|
||||
plan_id: z.number().default(0),
|
||||
expired_time: z.date().optional(),
|
||||
count: z
|
||||
.number()
|
||||
@ -48,7 +50,9 @@ export function getRedemptionFormSchema(t: TFunction) {
|
||||
|
||||
export type RedemptionFormValues = {
|
||||
name: string
|
||||
type: number
|
||||
quota_dollars: number
|
||||
plan_id: number
|
||||
expired_time?: Date
|
||||
count?: number
|
||||
}
|
||||
@ -59,7 +63,9 @@ export type RedemptionFormValues = {
|
||||
|
||||
export const REDEMPTION_FORM_DEFAULT_VALUES: RedemptionFormValues = {
|
||||
name: '',
|
||||
type: 1,
|
||||
quota_dollars: 10,
|
||||
plan_id: 0,
|
||||
expired_time: undefined,
|
||||
count: 1,
|
||||
}
|
||||
@ -76,7 +82,9 @@ export function transformFormDataToPayload(
|
||||
): RedemptionFormData {
|
||||
return {
|
||||
name: data.name,
|
||||
type: data.type,
|
||||
quota: parseQuotaFromDollars(data.quota_dollars),
|
||||
plan_id: data.plan_id,
|
||||
expired_time: data.expired_time
|
||||
? Math.floor(data.expired_time.getTime() / 1000)
|
||||
: 0,
|
||||
@ -92,7 +100,9 @@ export function transformRedemptionToFormDefaults(
|
||||
): RedemptionFormValues {
|
||||
return {
|
||||
name: redemption.name,
|
||||
type: redemption.type || 1,
|
||||
quota_dollars: quotaUnitsToDollars(redemption.quota),
|
||||
plan_id: redemption.plan_id || 0,
|
||||
expired_time:
|
||||
redemption.expired_time > 0
|
||||
? new Date(redemption.expired_time * 1000)
|
||||
|
||||
@ -28,7 +28,9 @@ export const redemptionSchema = z.object({
|
||||
name: z.string(),
|
||||
key: z.string(),
|
||||
status: z.number(), // 1: enabled, 2: disabled, 3: used
|
||||
type: z.number(), // 1: quota, 2: subscription
|
||||
quota: z.number(),
|
||||
plan_id: z.number(), // only when type=2
|
||||
created_time: z.number(),
|
||||
redeemed_time: z.number(),
|
||||
expired_time: z.number(), // 0 for never expires
|
||||
@ -72,7 +74,9 @@ export interface SearchRedemptionsParams {
|
||||
export interface RedemptionFormData {
|
||||
id?: number
|
||||
name: string
|
||||
type: number // 1: quota, 2: subscription
|
||||
quota: number
|
||||
plan_id: number // only when type=2
|
||||
expired_time: number
|
||||
count?: number // Only for create
|
||||
status?: number // Only for status update
|
||||
|
||||
@ -23,6 +23,8 @@ import { getSelf } from '@/lib/api'
|
||||
import { formatQuota } from '@/lib/format'
|
||||
import { redeemTopupCode } from '../api'
|
||||
|
||||
const REDEMPTION_TYPE_SUBSCRIPTION = 2
|
||||
|
||||
// ============================================================================
|
||||
// Redemption Hook
|
||||
// ============================================================================
|
||||
@ -41,12 +43,18 @@ export function useRedemption() {
|
||||
const response = await redeemTopupCode({ key: code })
|
||||
|
||||
if (response.success && response.data) {
|
||||
const quotaAdded = response.data
|
||||
toast.success(
|
||||
i18next.t('Redemption successful! Added: {{quota}}', {
|
||||
quota: formatQuota(quotaAdded),
|
||||
})
|
||||
)
|
||||
const result = response.data
|
||||
if (result.type === REDEMPTION_TYPE_SUBSCRIPTION) {
|
||||
toast.success(
|
||||
i18next.t('Redemption successful! Subscription plan activated')
|
||||
)
|
||||
} else {
|
||||
toast.success(
|
||||
i18next.t('Redemption successful! Added: {{quota}}', {
|
||||
quota: formatQuota(result.quota),
|
||||
})
|
||||
)
|
||||
}
|
||||
await getSelf()
|
||||
return true
|
||||
}
|
||||
|
||||
6
web/default/src/features/wallet/types.ts
vendored
6
web/default/src/features/wallet/types.ts
vendored
@ -33,7 +33,11 @@ export interface ApiResponse<T = unknown> {
|
||||
* Standard API response types
|
||||
*/
|
||||
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 PaymentResponse = ApiResponse<Record<string, unknown>> & {
|
||||
url?: string
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user