From 2f105a844d8dd278295d2d8ab558f058ca55c1f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=9A=E9=B9=8F=E6=9A=83?= <3492387549@qq.com> Date: Thu, 2 Jul 2026 18:05:01 +0800 Subject: [PATCH] feat(wallet): add affiliate withdrawal feature - add withdrawal UI in affiliate-rewards-card - add AffiliateWithdrawal API endpoints and routes - add withdrawal dialog integration - add AffiliateWithdrawal model and registration - add system settings for affiliate module - add i18n support (en, fr, ja) - update Go module dependencies (go.mod / go.sum) --- controller/option.go | 15 + controller/topup.go | 6 + controller/user.go | 141 ++++++- go.sum | 2 - main.go | 8 +- model/affiliate.go | 394 ++++++++++++++++++ model/affiliate_test.go | 155 +++++++ model/main.go | 6 + model/redemption.go | 6 +- model/task_cas_test.go | 6 + model/topup.go | 20 +- model/user.go | 50 ++- router/api-router.go | 4 + .../operation_setting/affiliate_setting.go | 21 + .../system-settings/billing/index.tsx | 5 + .../billing/section-registry.tsx | 8 + .../general/quota-settings-section.tsx | 140 +++++++ .../src/features/system-settings/types.ts | 5 + .../users/components/users-mutate-drawer.tsx | 112 +++++ .../src/features/users/lib/user-form.ts | 21 + web/default/src/features/users/types.ts | 14 + web/default/src/features/wallet/api.ts | 12 + .../components/affiliate-rewards-card.tsx | 42 +- .../dialogs/affiliate-withdrawal-dialog.tsx | 171 ++++++++ .../features/wallet/hooks/use-affiliate.ts | 38 +- web/default/src/features/wallet/index.tsx | 25 ++ web/default/src/features/wallet/types.ts | 41 +- web/default/src/i18n/locales/en.json | 25 ++ web/default/src/i18n/locales/fr.json | 25 ++ web/default/src/i18n/locales/ja.json | 25 ++ web/default/src/i18n/locales/ru.json | 25 ++ web/default/src/i18n/locales/vi.json | 25 ++ web/default/src/i18n/locales/zh.json | 25 ++ web/pnpm-lock.yaml | 5 + 34 files changed, 1580 insertions(+), 43 deletions(-) create mode 100644 model/affiliate.go create mode 100644 model/affiliate_test.go create mode 100644 setting/operation_setting/affiliate_setting.go create mode 100644 web/default/src/features/wallet/components/dialogs/affiliate-withdrawal-dialog.tsx create mode 100644 web/pnpm-lock.yaml diff --git a/controller/option.go b/controller/option.go index a97f07b841b7..4fe5d039f850 100644 --- a/controller/option.go +++ b/controller/option.go @@ -143,6 +143,21 @@ func UpdateOption(c *gin.Context) { common.ApiErrorI18n(c, i18n.MsgPaymentComplianceRequired) return } + case "affiliate_setting.enabled", "affiliate_setting.redemption_enabled", "affiliate_setting.withdraw_enabled": + if option.Value == "true" && !operation_setting.IsPaymentComplianceConfirmed() { + common.ApiErrorI18n(c, i18n.MsgPaymentComplianceRequired) + return + } + case "affiliate_setting.reward_percent": + percent, err := strconv.ParseFloat(strings.TrimSpace(option.Value.(string)), 64) + if err != nil || percent < 0 || percent > 100 { + common.ApiErrorMsg(c, "返利比例必须在 0 到 100 之间") + return + } + if percent > 0 && !operation_setting.IsPaymentComplianceConfirmed() { + common.ApiErrorI18n(c, i18n.MsgPaymentComplianceRequired) + return + } default: if isPaymentComplianceOptionKey(option.Key) { common.ApiErrorMsg(c, "合规确认字段不允许通过通用设置接口修改") diff --git a/controller/topup.go b/controller/topup.go index 69e1b5e304c4..aac97c526c34 100644 --- a/controller/topup.go +++ b/controller/topup.go @@ -23,6 +23,7 @@ import ( func GetTopUpInfo(c *gin.Context) { complianceConfirmed := operation_setting.IsPaymentComplianceConfirmed() + affiliateSetting := operation_setting.GetAffiliateSetting() // 获取支付方式 payMethods := operation_setting.PayMethods @@ -104,6 +105,8 @@ func GetTopUpInfo(c *gin.Context) { "enable_redemption": complianceConfirmed, "payment_compliance_confirmed": complianceConfirmed, "payment_compliance_terms_version": operation_setting.CurrentComplianceTermsVersion, + "affiliate_enabled": complianceConfirmed && affiliateSetting.Enabled, + "affiliate_withdraw_enabled": complianceConfirmed && affiliateSetting.Enabled && affiliateSetting.WithdrawEnabled, "waffo_pay_methods": func() interface{} { if enableWaffo { return setting.GetWaffoPayMethods() @@ -399,6 +402,9 @@ func EpayNotify(c *gin.Context) { dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit) quotaToAdd := int(dAmount.Mul(dQuotaPerUnit).IntPart()) err = model.IncreaseUserQuota(topUp.UserId, quotaToAdd, true) + if err == nil { + err = model.CreateAffiliateRebateForTopUp(topUp, quotaToAdd) + } if err != nil { logger.LogError(c.Request.Context(), fmt.Sprintf("易支付 更新用户额度失败 trade_no=%s user_id=%d client_ip=%s quota_to_add=%d error=%q topup=%q", topUp.TradeNo, topUp.UserId, c.ClientIP(), quotaToAdd, err.Error(), common.GetJsonString(topUp))) return diff --git a/controller/user.go b/controller/user.go index 1fc52dd90cb1..6bdb269ae8af 100644 --- a/controller/user.go +++ b/controller/user.go @@ -320,6 +320,47 @@ func canManageTargetRole(myRole int, targetRole int) bool { return myRole == common.RoleRootUser || myRole > targetRole } +func getAffiliateRulePayload(userId int) (*model.AffiliateUserRulePayload, error) { + global := operation_setting.GetAffiliateSetting() + payload := &model.AffiliateUserRulePayload{ + Enabled: global.Enabled, + RewardPercent: global.RewardPercent, + SettleAfterInviteeConsumed: global.SettleAfterInviteeConsumed, + } + rule, found, err := model.GetAffiliateUserRule(userId) + if err != nil { + return nil, err + } + if found { + payload.Custom = true + payload.Enabled = rule.Enabled + payload.RewardPercent = rule.RewardPercent + payload.SettleAfterInviteeConsumed = rule.SettleAfterInviteeConsumed + } + return payload, nil +} + +func updateAffiliateRuleForUserInTx(tx *gorm.DB, userId int, payload *model.AffiliateUserRulePayload) error { + if payload == nil { + return nil + } + if !payload.Custom { + return model.DeleteAffiliateUserRuleWithTx(tx, userId) + } + if payload.RewardPercent < 0 || payload.RewardPercent > 100 { + return errors.New("affiliate reward percent must be between 0 and 100") + } + if payload.Enabled && payload.RewardPercent > 0 && !operation_setting.IsPaymentComplianceConfirmed() { + return model.ErrAffiliateRuleDisabled + } + return model.SaveAffiliateUserRuleWithTx(tx, &model.AffiliateUserRule{ + UserId: userId, + Enabled: payload.Enabled, + RewardPercent: payload.RewardPercent, + SettleAfterInviteeConsumed: payload.SettleAfterInviteeConsumed, + }) +} + func GetUser(c *gin.Context) { id, err := strconv.Atoi(c.Param("id")) if err != nil { @@ -337,6 +378,11 @@ func GetUser(c *gin.Context) { return } user.AdminPermissions = authz.Capabilities(user.Id, user.Role) + user.AffiliateRule, err = getAffiliateRulePayload(user.Id) + if err != nil { + common.ApiError(c, err) + return + } c.JSON(http.StatusOK, gin.H{ "success": true, "message": "", @@ -408,6 +454,83 @@ func TransferAffQuota(c *gin.Context) { common.ApiSuccessI18n(c, i18n.MsgUserTransferSuccess, nil) } +type AffiliateWithdrawalRequest struct { + Amount int `json:"amount"` + PaymentMethod string `json:"payment_method"` + Account string `json:"account"` + Remark string `json:"remark"` +} + +func CreateAffiliateWithdrawal(c *gin.Context) { + var req AffiliateWithdrawalRequest + if err := common.DecodeJson(c.Request.Body, &req); err != nil { + common.ApiErrorI18n(c, i18n.MsgInvalidParams) + return + } + req.PaymentMethod = strings.TrimSpace(req.PaymentMethod) + req.Account = strings.TrimSpace(req.Account) + req.Remark = strings.TrimSpace(req.Remark) + if req.Amount <= 0 || req.PaymentMethod == "" || req.Account == "" { + common.ApiErrorI18n(c, i18n.MsgInvalidParams) + return + } + withdrawal, err := model.CreateAffiliateWithdrawal(c.GetInt("id"), req.Amount, req.PaymentMethod, req.Account, req.Remark) + if err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, withdrawal) +} + +func GetAffiliateWithdrawals(c *gin.Context) { + pageInfo := common.GetPageQuery(c) + withdrawals, total, err := model.GetUserAffiliateWithdrawals(c.GetInt("id"), pageInfo) + if err != nil { + common.ApiError(c, err) + return + } + pageInfo.SetTotal(int(total)) + pageInfo.SetItems(withdrawals) + common.ApiSuccess(c, pageInfo) +} + +type AffiliateWithdrawalProcessRequest struct { + Status string `json:"status"` + AdminRemark string `json:"admin_remark"` +} + +func GetAllAffiliateWithdrawals(c *gin.Context) { + pageInfo := common.GetPageQuery(c) + withdrawals, total, err := model.GetAllAffiliateWithdrawals(pageInfo) + if err != nil { + common.ApiError(c, err) + return + } + pageInfo.SetTotal(int(total)) + pageInfo.SetItems(withdrawals) + common.ApiSuccess(c, pageInfo) +} + +func ProcessAffiliateWithdrawal(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + common.ApiErrorI18n(c, i18n.MsgInvalidParams) + return + } + var req AffiliateWithdrawalProcessRequest + if err := common.DecodeJson(c.Request.Body, &req); err != nil { + common.ApiErrorI18n(c, i18n.MsgInvalidParams) + return + } + req.Status = strings.TrimSpace(req.Status) + req.AdminRemark = strings.TrimSpace(req.AdminRemark) + if err := model.UpdateAffiliateWithdrawalStatus(id, req.Status, req.AdminRemark, c.GetInt("id")); err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, nil) +} + func GetAffCode(c *gin.Context) { id := c.GetInt("id") user, err := model.GetUserById(id, true) @@ -450,6 +573,16 @@ func GetSelf(c *gin.Context) { // 获取用户设置并提取sidebar_modules userSetting := user.GetSetting() + pendingAffiliateQuota, err := model.GetPendingAffiliateQuota(user.Id) + if err != nil { + common.SysLog(fmt.Sprintf("failed to get pending affiliate quota for user %d: %s", user.Id, err.Error())) + pendingAffiliateQuota = 0 + } + affiliateInviteCount, err := model.GetAffiliateInviteCount(user.Id) + if err != nil { + common.SysLog(fmt.Sprintf("failed to get affiliate invite count for user %d: %s", user.Id, err.Error())) + affiliateInviteCount = user.AffCount + } // 构建响应数据,包含用户信息和权限 responseData := map[string]interface{}{ @@ -469,8 +602,9 @@ func GetSelf(c *gin.Context) { "used_quota": user.UsedQuota, "request_count": user.RequestCount, "aff_code": user.AffCode, - "aff_count": user.AffCount, + "aff_count": affiliateInviteCount, "aff_quota": user.AffQuota, + "aff_pending_quota": pendingAffiliateQuota, "aff_history_quota": user.AffHistoryQuota, "inviter_id": user.InviterId, "linux_do_id": user.LinuxDOId, @@ -664,7 +798,10 @@ func UpdateUser(c *gin.Context) { } touched, err := updateAdminPermissionsForUserInTx(c, tx, updatedUser.Id, originUser.Role, updatedUser.AdminPermissions) authzTouched = touched - return err + if err != nil { + return err + } + return updateAffiliateRuleForUserInTx(tx, updatedUser.Id, updatedUser.AffiliateRule) }); err != nil { common.ApiError(c, err) return diff --git a/go.sum b/go.sum index 836fe4639bc0..d7fae89c24c7 100644 --- a/go.sum +++ b/go.sum @@ -2000,8 +2000,6 @@ github.com/vishvananda/netns v0.0.0-20180720170159-13995c7128cc/go.mod h1:ZjcWmF github.com/vishvananda/netns v0.0.0-20191106174202-0a2b9b5464df/go.mod h1:JP3t17pCcGlemwknint6hfoeCVQrEMVwxRLRjXpq+BU= github.com/vishvananda/netns v0.0.0-20200728191858-db3c7e526aae/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0= github.com/vishvananda/netns v0.0.0-20210104183010-2eb08e3e575f/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0= -github.com/waffo-com/waffo-go v1.3.1 h1:NCYD3oQ59DTJj1bwS5T/659LI4h8PuAIW4Qj/w7fKPw= -github.com/waffo-com/waffo-go v1.3.1/go.mod h1:IaXVYq6mmYtrLFFsLxPslNwuIZx0mIadWWjhe+eWb0g= github.com/waffo-com/waffo-go v1.3.2 h1:HCaG7hPcj4vGIW5rJ4J8DI6BHuvO4Nt0ChsQc39pazs= github.com/waffo-com/waffo-go v1.3.2/go.mod h1:IaXVYq6mmYtrLFFsLxPslNwuIZx0mIadWWjhe+eWb0g= github.com/waffo-com/waffo-pancake-sdk-go v0.3.1 h1:ngQSN/oVB35xTwFPLfg++bxPC+SptcF145Mb6c62YCc= diff --git a/main.go b/main.go index c157bc0a0e0b..65cccfda47c2 100644 --- a/main.go +++ b/main.go @@ -36,16 +36,16 @@ import ( _ "net/http/pprof" ) -//go:embed web/default/dist +// go:embed web/default/dist var buildFS embed.FS -//go:embed web/default/dist/index.html +// go:embed web/default/dist/index.html var indexPage []byte -//go:embed web/classic/dist +// go:embed web/classic/dist var classicBuildFS embed.FS -//go:embed web/classic/dist/index.html +// go:embed web/classic/dist/index.html var classicIndexPage []byte func main() { diff --git a/model/affiliate.go b/model/affiliate.go new file mode 100644 index 000000000000..6fc43a86ccbc --- /dev/null +++ b/model/affiliate.go @@ -0,0 +1,394 @@ +package model + +import ( + "errors" + "fmt" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/setting/operation_setting" + "github.com/shopspring/decimal" + "gorm.io/gorm" +) + +const ( + AffiliateRebateStatusPending = "pending" + AffiliateRebateStatusAvailable = "available" + + AffiliateWithdrawalStatusPending = "pending" + AffiliateWithdrawalStatusPaid = "paid" + AffiliateWithdrawalStatusRejected = "rejected" +) + +var ( + ErrAffiliateRuleDisabled = errors.New("affiliate rebate is disabled") + ErrAffiliateWithdrawalDisabled = errors.New("affiliate withdrawal is disabled") + ErrAffiliateQuotaInsufficient = errors.New("affiliate quota is insufficient") + ErrAffiliateWithdrawalInvalid = errors.New("affiliate withdrawal status is invalid") +) + +type AffiliateUserRule struct { + Id int `json:"id"` + UserId int `json:"user_id" gorm:"uniqueIndex;not null"` + Enabled bool `json:"enabled"` + RewardPercent float64 `json:"reward_percent"` + SettleAfterInviteeConsumed bool `json:"settle_after_invitee_consumed"` + CreatedAt int64 `json:"created_at" gorm:"autoCreateTime"` + UpdatedAt int64 `json:"updated_at" gorm:"autoUpdateTime"` +} + +type AffiliateRebate struct { + Id int `json:"id"` + InviterId int `json:"inviter_id" gorm:"index"` + InviteeId int `json:"invitee_id" gorm:"index"` + TopUpId int `json:"topup_id" gorm:"uniqueIndex"` + TradeNo string `json:"trade_no" gorm:"type:varchar(255);uniqueIndex"` + TopUpQuota int `json:"topup_quota"` + TopUpMoney float64 `json:"topup_money"` + RewardQuota int `json:"reward_quota"` + RewardPercent float64 `json:"reward_percent"` + SettleAfterInviteeConsumed bool `json:"settle_after_invitee_consumed"` + ReleaseUsedQuota int `json:"release_used_quota"` + Status string `json:"status" gorm:"type:varchar(32);index"` + CreatedAt int64 `json:"created_at" gorm:"autoCreateTime"` + ReleasedAt int64 `json:"released_at"` +} + +type AffiliateWithdrawal struct { + Id int `json:"id"` + UserId int `json:"user_id" gorm:"index"` + Amount int `json:"amount"` + PaymentMethod string `json:"payment_method" gorm:"type:varchar(64)"` + Account string `json:"account" gorm:"type:varchar(255)"` + Remark string `json:"remark" gorm:"type:varchar(255)"` + AdminRemark string `json:"admin_remark" gorm:"type:varchar(255)"` + Status string `json:"status" gorm:"type:varchar(32);index"` + CreatedAt int64 `json:"created_at" gorm:"autoCreateTime"` + UpdatedAt int64 `json:"updated_at" gorm:"autoUpdateTime"` + ProcessedAt int64 `json:"processed_at"` + ProcessedBy int `json:"processed_by"` +} + +type effectiveAffiliateRule struct { + Enabled bool + RewardPercent float64 + SettleAfterInviteeConsumed bool +} + +func GetAffiliateUserRule(userId int) (*AffiliateUserRule, bool, error) { + var rule AffiliateUserRule + err := DB.Where("user_id = ?", userId).First(&rule).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, false, nil + } + if err != nil { + return nil, false, err + } + return &rule, true, nil +} + +func SaveAffiliateUserRule(rule *AffiliateUserRule) error { + return DB.Transaction(func(tx *gorm.DB) error { + return SaveAffiliateUserRuleWithTx(tx, rule) + }) +} + +func SaveAffiliateUserRuleWithTx(tx *gorm.DB, rule *AffiliateUserRule) error { + if rule.UserId == 0 { + return errors.New("user id is empty") + } + var existing AffiliateUserRule + err := tx.Where("user_id = ?", rule.UserId).First(&existing).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return tx.Create(rule).Error + } + if err != nil { + return err + } + existing.Enabled = rule.Enabled + existing.RewardPercent = rule.RewardPercent + existing.SettleAfterInviteeConsumed = rule.SettleAfterInviteeConsumed + return tx.Save(&existing).Error +} + +func DeleteAffiliateUserRule(userId int) error { + return DB.Where("user_id = ?", userId).Delete(&AffiliateUserRule{}).Error +} + +func DeleteAffiliateUserRuleWithTx(tx *gorm.DB, userId int) error { + return tx.Where("user_id = ?", userId).Delete(&AffiliateUserRule{}).Error +} + +func GetAffiliateInviteCount(userId int) (int, error) { + var count int64 + err := DB.Model(&User{}).Where("inviter_id = ?", userId).Count(&count).Error + return int(count), err +} + +func getEffectiveAffiliateRuleTx(tx *gorm.DB, inviterId int) (effectiveAffiliateRule, error) { + global := operation_setting.GetAffiliateSetting() + if !global.Enabled { + return effectiveAffiliateRule{}, nil + } + + rule := effectiveAffiliateRule{ + Enabled: true, + RewardPercent: global.RewardPercent, + SettleAfterInviteeConsumed: global.SettleAfterInviteeConsumed, + } + + var userRule AffiliateUserRule + err := tx.Where("user_id = ?", inviterId).First(&userRule).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return rule, nil + } + if err != nil { + return effectiveAffiliateRule{}, err + } + + rule.Enabled = userRule.Enabled + rule.RewardPercent = userRule.RewardPercent + rule.SettleAfterInviteeConsumed = userRule.SettleAfterInviteeConsumed + return rule, nil +} + +func addAffiliateQuotaTx(tx *gorm.DB, userId int, amount int, includeHistory bool) error { + if amount <= 0 { + return nil + } + updates := map[string]interface{}{ + "aff_quota": gorm.Expr("aff_quota + ?", amount), + } + if includeHistory { + updates["aff_history"] = gorm.Expr("aff_history + ?", amount) + } + return tx.Model(&User{}).Where("id = ?", userId).Updates(updates).Error +} + +func createAffiliateRebateForQuotaTx(tx *gorm.DB, inviteeId int, sourceId int, sourceTradeNo string, sourceQuota int, sourceMoney float64) error { + if inviteeId == 0 || sourceId == 0 || sourceTradeNo == "" || sourceQuota <= 0 { + return nil + } + if !operation_setting.IsPaymentComplianceConfirmed() { + return nil + } + + var invitee User + if err := tx.Select("id", "inviter_id", "used_quota").Where("id = ?", inviteeId).First(&invitee).Error; err != nil { + return err + } + if invitee.InviterId == 0 { + return nil + } + + rule, err := getEffectiveAffiliateRuleTx(tx, invitee.InviterId) + if err != nil { + return err + } + if !rule.Enabled || rule.RewardPercent <= 0 { + return nil + } + + var existing int64 + if err := tx.Model(&AffiliateRebate{}).Where("top_up_id = ? OR trade_no = ?", sourceId, sourceTradeNo).Count(&existing).Error; err != nil { + return err + } + if existing > 0 { + return nil + } + + rewardQuota := int(decimal.NewFromInt(int64(sourceQuota)). + Mul(decimal.NewFromFloat(rule.RewardPercent)). + Div(decimal.NewFromInt(100)). + IntPart()) + if rewardQuota <= 0 { + return nil + } + + status := AffiliateRebateStatusAvailable + releaseUsedQuota := 0 + if rule.SettleAfterInviteeConsumed { + status = AffiliateRebateStatusPending + releaseUsedQuota = invitee.UsedQuota + sourceQuota + } + + rebate := AffiliateRebate{ + InviterId: invitee.InviterId, + InviteeId: invitee.Id, + TopUpId: sourceId, + TradeNo: sourceTradeNo, + TopUpQuota: sourceQuota, + TopUpMoney: sourceMoney, + RewardQuota: rewardQuota, + RewardPercent: rule.RewardPercent, + SettleAfterInviteeConsumed: rule.SettleAfterInviteeConsumed, + ReleaseUsedQuota: releaseUsedQuota, + Status: status, + } + if status == AffiliateRebateStatusAvailable { + rebate.ReleasedAt = common.GetTimestamp() + } + if err := tx.Create(&rebate).Error; err != nil { + return err + } + if status == AffiliateRebateStatusAvailable { + return addAffiliateQuotaTx(tx, invitee.InviterId, rewardQuota, true) + } + return nil +} + +func CreateAffiliateRebateForTopUpTx(tx *gorm.DB, topUp *TopUp, topUpQuota int) error { + if topUp == nil || topUp.Id == 0 || topUp.UserId == 0 { + return nil + } + return createAffiliateRebateForQuotaTx(tx, topUp.UserId, topUp.Id, topUp.TradeNo, topUpQuota, topUp.Money) +} + +func CreateAffiliateRebateForTopUp(topUp *TopUp, topUpQuota int) error { + return DB.Transaction(func(tx *gorm.DB) error { + return CreateAffiliateRebateForTopUpTx(tx, topUp, topUpQuota) + }) +} + +func CreateAffiliateRebateForRedemptionTx(tx *gorm.DB, redemption *Redemption, userId int) error { + if redemption == nil || redemption.Id == 0 || userId == 0 || redemption.Quota <= 0 { + return nil + } + if !operation_setting.GetAffiliateSetting().RedemptionEnabled { + return nil + } + sourceTradeNo := fmt.Sprintf("redemption:%d", redemption.Id) + return createAffiliateRebateForQuotaTx(tx, userId, -redemption.Id, sourceTradeNo, redemption.Quota, 0) +} + +func ReleaseEligibleAffiliateRebatesForInvitee(inviteeId int) error { + if inviteeId == 0 { + return nil + } + return DB.Transaction(func(tx *gorm.DB) error { + var invitee User + if err := tx.Select("id", "used_quota").Where("id = ?", inviteeId).First(&invitee).Error; err != nil { + return err + } + + var rebates []AffiliateRebate + if err := tx.Where( + "invitee_id = ? AND status = ? AND release_used_quota <= ?", + inviteeId, + AffiliateRebateStatusPending, + invitee.UsedQuota, + ).Find(&rebates).Error; err != nil { + return err + } + + for _, rebate := range rebates { + res := tx.Model(&AffiliateRebate{}). + Where("id = ? AND status = ?", rebate.Id, AffiliateRebateStatusPending). + Updates(map[string]interface{}{ + "status": AffiliateRebateStatusAvailable, + "released_at": common.GetTimestamp(), + }) + if res.Error != nil { + return res.Error + } + if res.RowsAffected == 0 { + continue + } + if err := addAffiliateQuotaTx(tx, rebate.InviterId, rebate.RewardQuota, true); err != nil { + return err + } + } + return nil + }) +} + +func GetPendingAffiliateQuota(userId int) (int, error) { + var total int64 + err := DB.Model(&AffiliateRebate{}). + Where("inviter_id = ? AND status = ?", userId, AffiliateRebateStatusPending). + Select("COALESCE(SUM(reward_quota), 0)"). + Scan(&total).Error + return int(total), err +} + +func CreateAffiliateWithdrawal(userId int, amount int, paymentMethod string, account string, remark string) (*AffiliateWithdrawal, error) { + if !operation_setting.IsPaymentComplianceConfirmed() { + return nil, ErrAffiliateRuleDisabled + } + affiliateSetting := operation_setting.GetAffiliateSetting() + if !affiliateSetting.Enabled || !affiliateSetting.WithdrawEnabled { + return nil, ErrAffiliateWithdrawalDisabled + } + if amount <= 0 { + return nil, errors.New("withdraw amount must be greater than zero") + } + + withdrawal := &AffiliateWithdrawal{ + UserId: userId, + Amount: amount, + PaymentMethod: paymentMethod, + Account: account, + Remark: remark, + Status: AffiliateWithdrawalStatusPending, + } + + err := DB.Transaction(func(tx *gorm.DB) error { + var user User + if err := tx.Set("gorm:query_option", "FOR UPDATE").Where("id = ?", userId).First(&user).Error; err != nil { + return err + } + if user.AffQuota < amount { + return ErrAffiliateQuotaInsufficient + } + if err := tx.Model(&User{}).Where("id = ?", userId).Update("aff_quota", gorm.Expr("aff_quota - ?", amount)).Error; err != nil { + return err + } + return tx.Create(withdrawal).Error + }) + if err != nil { + return nil, err + } + return withdrawal, nil +} + +func GetUserAffiliateWithdrawals(userId int, pageInfo *common.PageInfo) (withdrawals []*AffiliateWithdrawal, total int64, err error) { + query := DB.Model(&AffiliateWithdrawal{}).Where("user_id = ?", userId) + if err = query.Count(&total).Error; err != nil { + return nil, 0, err + } + err = query.Order("id desc").Limit(pageInfo.GetPageSize()).Offset(pageInfo.GetStartIdx()).Find(&withdrawals).Error + return withdrawals, total, err +} + +func GetAllAffiliateWithdrawals(pageInfo *common.PageInfo) (withdrawals []*AffiliateWithdrawal, total int64, err error) { + query := DB.Model(&AffiliateWithdrawal{}) + if err = query.Count(&total).Error; err != nil { + return nil, 0, err + } + err = query.Order("id desc").Limit(pageInfo.GetPageSize()).Offset(pageInfo.GetStartIdx()).Find(&withdrawals).Error + return withdrawals, total, err +} + +func UpdateAffiliateWithdrawalStatus(id int, status string, adminRemark string, operatorId int) error { + if status != AffiliateWithdrawalStatusPaid && status != AffiliateWithdrawalStatusRejected { + return ErrAffiliateWithdrawalInvalid + } + return DB.Transaction(func(tx *gorm.DB) error { + var withdrawal AffiliateWithdrawal + if err := tx.Set("gorm:query_option", "FOR UPDATE").Where("id = ?", id).First(&withdrawal).Error; err != nil { + return err + } + if withdrawal.Status != AffiliateWithdrawalStatusPending { + return ErrAffiliateWithdrawalInvalid + } + if status == AffiliateWithdrawalStatusRejected { + if err := tx.Model(&User{}).Where("id = ?", withdrawal.UserId).Update("aff_quota", gorm.Expr("aff_quota + ?", withdrawal.Amount)).Error; err != nil { + return err + } + } + withdrawal.Status = status + withdrawal.AdminRemark = adminRemark + withdrawal.ProcessedAt = common.GetTimestamp() + withdrawal.ProcessedBy = operatorId + return tx.Save(&withdrawal).Error + }) +} diff --git a/model/affiliate_test.go b/model/affiliate_test.go new file mode 100644 index 000000000000..f22946946b80 --- /dev/null +++ b/model/affiliate_test.go @@ -0,0 +1,155 @@ +package model + +import ( + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/setting/operation_setting" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func setAffiliateTestSettings(t *testing.T, enabled bool, percent float64, settleAfterConsumed bool, withdrawEnabled bool, complianceConfirmed bool) { + t.Helper() + affiliateSetting := operation_setting.GetAffiliateSetting() + oldAffiliateSetting := *affiliateSetting + paymentSetting := operation_setting.GetPaymentSetting() + oldPaymentSetting := *paymentSetting + + affiliateSetting.Enabled = enabled + affiliateSetting.RewardPercent = percent + affiliateSetting.SettleAfterInviteeConsumed = settleAfterConsumed + affiliateSetting.WithdrawEnabled = withdrawEnabled + paymentSetting.ComplianceConfirmed = complianceConfirmed + paymentSetting.ComplianceTermsVersion = operation_setting.CurrentComplianceTermsVersion + + t.Cleanup(func() { + *affiliateSetting = oldAffiliateSetting + *paymentSetting = oldPaymentSetting + }) +} + +func createAffiliateTestUser(t *testing.T, user *User) *User { + t.Helper() + if user.AffCode == "" { + user.AffCode = user.Username + "-aff" + } + require.NoError(t, DB.Create(user).Error) + return user +} + +func createAffiliateTestTopUp(t *testing.T, userId int, tradeNo string, money float64) *TopUp { + t.Helper() + topUp := &TopUp{ + UserId: userId, + Amount: int64(money), + Money: money, + TradeNo: tradeNo, + PaymentMethod: PaymentMethodStripe, + PaymentProvider: PaymentProviderStripe, + Status: common.TopUpStatusSuccess, + } + require.NoError(t, DB.Create(topUp).Error) + return topUp +} + +func TestInviteCountIncrementsWithoutRegistrationReward(t *testing.T) { + truncateTables(t) + setAffiliateTestSettings(t, false, 0, false, false, false) + + inviter := createAffiliateTestUser(t, &User{Username: "inviter-no-reward"}) + + require.NoError(t, inviteUser(inviter.Id, 0)) + + var reloaded User + require.NoError(t, DB.First(&reloaded, inviter.Id).Error) + assert.Equal(t, 1, reloaded.AffCount) + assert.Equal(t, 0, reloaded.AffQuota) + assert.Equal(t, 0, reloaded.AffHistoryQuota) +} + +func TestCreateAffiliateRebateFeatureOffDoesNothing(t *testing.T) { + truncateTables(t) + setAffiliateTestSettings(t, false, 10, false, false, true) + + inviter := createAffiliateTestUser(t, &User{Username: "inviter-off"}) + invitee := createAffiliateTestUser(t, &User{Username: "invitee-off", InviterId: inviter.Id}) + topUp := createAffiliateTestTopUp(t, invitee.Id, "trade-aff-off", 10) + + require.NoError(t, CreateAffiliateRebateForTopUp(topUp, 1000)) + + var count int64 + require.NoError(t, DB.Model(&AffiliateRebate{}).Count(&count).Error) + assert.EqualValues(t, 0, count) +} + +func TestCreateAffiliateRebateAvailableImmediately(t *testing.T) { + truncateTables(t) + setAffiliateTestSettings(t, true, 10, false, false, true) + + inviter := createAffiliateTestUser(t, &User{Username: "inviter-available"}) + invitee := createAffiliateTestUser(t, &User{Username: "invitee-available", InviterId: inviter.Id}) + topUp := createAffiliateTestTopUp(t, invitee.Id, "trade-aff-available", 10) + + require.NoError(t, CreateAffiliateRebateForTopUp(topUp, 1000)) + + var rebate AffiliateRebate + require.NoError(t, DB.First(&rebate, "trade_no = ?", topUp.TradeNo).Error) + assert.Equal(t, AffiliateRebateStatusAvailable, rebate.Status) + assert.Equal(t, 100, rebate.RewardQuota) + + var reloaded User + require.NoError(t, DB.First(&reloaded, inviter.Id).Error) + assert.Equal(t, 100, reloaded.AffQuota) + assert.Equal(t, 100, reloaded.AffHistoryQuota) +} + +func TestPendingAffiliateRebateReleasesAfterInviteeConsumesTopUp(t *testing.T) { + truncateTables(t) + setAffiliateTestSettings(t, true, 10, true, false, true) + + inviter := createAffiliateTestUser(t, &User{Username: "inviter-pending"}) + invitee := createAffiliateTestUser(t, &User{Username: "invitee-pending", InviterId: inviter.Id, UsedQuota: 20}) + topUp := createAffiliateTestTopUp(t, invitee.Id, "trade-aff-pending", 10) + + require.NoError(t, CreateAffiliateRebateForTopUp(topUp, 100)) + + pendingQuota, err := GetPendingAffiliateQuota(inviter.Id) + require.NoError(t, err) + assert.Equal(t, 10, pendingQuota) + + updateUserUsedQuotaAndRequestCount(invitee.Id, 99, 1) + var notReleased User + require.NoError(t, DB.First(¬Released, inviter.Id).Error) + assert.Equal(t, 0, notReleased.AffQuota) + + updateUserUsedQuotaAndRequestCount(invitee.Id, 1, 1) + var released User + require.NoError(t, DB.First(&released, inviter.Id).Error) + assert.Equal(t, 10, released.AffQuota) + assert.Equal(t, 10, released.AffHistoryQuota) + + var rebate AffiliateRebate + require.NoError(t, DB.First(&rebate, "trade_no = ?", topUp.TradeNo).Error) + assert.Equal(t, AffiliateRebateStatusAvailable, rebate.Status) +} + +func TestAffiliateWithdrawalDeductsAndRejectRefunds(t *testing.T) { + truncateTables(t) + setAffiliateTestSettings(t, true, 10, false, true, true) + + user := createAffiliateTestUser(t, &User{Username: "withdraw-user", AffQuota: 100}) + + withdrawal, err := CreateAffiliateWithdrawal(user.Id, 40, "manual", "account-id", "remark") + require.NoError(t, err) + assert.Equal(t, AffiliateWithdrawalStatusPending, withdrawal.Status) + + var afterCreate User + require.NoError(t, DB.First(&afterCreate, user.Id).Error) + assert.Equal(t, 60, afterCreate.AffQuota) + + require.NoError(t, UpdateAffiliateWithdrawalStatus(withdrawal.Id, AffiliateWithdrawalStatusRejected, "bad account", 1)) + var afterReject User + require.NoError(t, DB.First(&afterReject, user.Id).Error) + assert.Equal(t, 100, afterReject.AffQuota) +} diff --git a/model/main.go b/model/main.go index 76f98a59c307..b0fb8c399a47 100644 --- a/model/main.go +++ b/model/main.go @@ -279,6 +279,9 @@ func migrateDB() error { &Log{}, &Midjourney{}, &TopUp{}, + &AffiliateUserRule{}, + &AffiliateRebate{}, + &AffiliateWithdrawal{}, &QuotaData{}, &Task{}, &Model{}, @@ -333,6 +336,9 @@ func migrateDBFast() error { {&Log{}, "Log"}, {&Midjourney{}, "Midjourney"}, {&TopUp{}, "TopUp"}, + {&AffiliateUserRule{}, "AffiliateUserRule"}, + {&AffiliateRebate{}, "AffiliateRebate"}, + {&AffiliateWithdrawal{}, "AffiliateWithdrawal"}, {&QuotaData{}, "QuotaData"}, {&Task{}, "Task"}, {&Model{}, "Model"}, diff --git a/model/redemption.go b/model/redemption.go index f64361fc86dc..209dfcee6ca2 100644 --- a/model/redemption.go +++ b/model/redemption.go @@ -144,8 +144,10 @@ func Redeem(key string, userId int) (quota int, err error) { redemption.RedeemedTime = common.GetTimestamp() redemption.Status = common.RedemptionCodeStatusUsed redemption.UsedUserId = userId - err = tx.Save(redemption).Error - return err + if err = tx.Save(redemption).Error; err != nil { + return err + } + return CreateAffiliateRebateForRedemptionTx(tx, redemption, userId) }) if err != nil { common.SysError("redemption failed: " + err.Error()) diff --git a/model/task_cas_test.go b/model/task_cas_test.go index 479774cd3fd5..5f6529bf475c 100644 --- a/model/task_cas_test.go +++ b/model/task_cas_test.go @@ -43,6 +43,9 @@ func TestMain(m *testing.M) { &QuotaData{}, &Ability{}, &TopUp{}, + &AffiliateUserRule{}, + &AffiliateRebate{}, + &AffiliateWithdrawal{}, &SubscriptionPlan{}, &SubscriptionOrder{}, &UserSubscription{}, @@ -69,6 +72,9 @@ func truncateTables(t *testing.T) { DB.Exec("DELETE FROM quota_data") DB.Exec("DELETE FROM abilities") DB.Exec("DELETE FROM top_ups") + DB.Exec("DELETE FROM affiliate_user_rules") + DB.Exec("DELETE FROM affiliate_rebates") + DB.Exec("DELETE FROM affiliate_withdrawals") DB.Exec("DELETE FROM subscription_orders") DB.Exec("DELETE FROM subscription_plans") DB.Exec("DELETE FROM user_subscriptions") diff --git a/model/topup.go b/model/topup.go index 8d7225081f71..987b57d68cf9 100644 --- a/model/topup.go +++ b/model/topup.go @@ -111,7 +111,8 @@ func Recharge(referenceId string, customerId string, callerIp string) (err error return errors.New("未提供支付单号") } - var quota float64 + var quota int + var quotaToAdd int topUp := &TopUp{} refCol := "`trade_no`" @@ -140,13 +141,14 @@ func Recharge(referenceId string, customerId string, callerIp string) (err error return err } - quota = topUp.Money * common.QuotaPerUnit - err = tx.Model(&User{}).Where("id = ?", topUp.UserId).Updates(map[string]interface{}{"stripe_customer": customerId, "quota": gorm.Expr("quota + ?", quota)}).Error + quotaToAdd = int(decimal.NewFromFloat(topUp.Money).Mul(decimal.NewFromFloat(common.QuotaPerUnit)).IntPart()) + quota = quotaToAdd + err = tx.Model(&User{}).Where("id = ?", topUp.UserId).Updates(map[string]interface{}{"stripe_customer": customerId, "quota": gorm.Expr("quota + ?", quotaToAdd)}).Error if err != nil { return err } - return nil + return CreateAffiliateRebateForTopUpTx(tx, topUp, quotaToAdd) }) if err != nil { @@ -375,6 +377,10 @@ func ManualCompleteTopUp(tradeNo string, callerIp string) error { return err } + if err := CreateAffiliateRebateForTopUpTx(tx, topUp, quotaToAdd); err != nil { + return err + } + userId = topUp.UserId payMoney = topUp.Money paymentMethod = topUp.PaymentMethod @@ -451,7 +457,7 @@ func RechargeCreem(referenceId string, customerEmail string, customerName string return err } - return nil + return CreateAffiliateRebateForTopUpTx(tx, topUp, int(quota)) }) if err != nil { @@ -512,7 +518,7 @@ func RechargeWaffo(tradeNo string, callerIp string) (err error) { return err } - return nil + return CreateAffiliateRebateForTopUpTx(tx, topUp, quotaToAdd) }) if err != nil { @@ -573,7 +579,7 @@ func RechargeWaffoPancake(tradeNo string) (err error) { return err } - return nil + return CreateAffiliateRebateForTopUpTx(tx, topUp, quotaToAdd) }) if err != nil { diff --git a/model/user.go b/model/user.go index 85bbc7c4515c..2e3fe31edab7 100644 --- a/model/user.go +++ b/model/user.go @@ -54,6 +54,14 @@ type User struct { CreatedAt int64 `json:"created_at" gorm:"autoCreateTime;column:created_at"` LastLoginAt int64 `json:"last_login_at" gorm:"default:0;column:last_login_at"` AdminPermissions map[string]map[string]bool `json:"admin_permissions,omitempty" gorm:"-:all"` + AffiliateRule *AffiliateUserRulePayload `json:"affiliate_rule,omitempty" gorm:"-:all"` +} + +type AffiliateUserRulePayload struct { + Custom bool `json:"custom"` + Enabled bool `json:"enabled"` + RewardPercent float64 `json:"reward_percent"` + SettleAfterInviteeConsumed bool `json:"settle_after_invitee_consumed"` } func (user *User) ToBaseUser() *UserBase { @@ -337,14 +345,16 @@ func HardDeleteUserById(id int) error { }) } -func inviteUser(inviterId int) (err error) { +func inviteUser(inviterId int, rewardQuota int) (err error) { user, err := GetUserById(inviterId, true) if err != nil { return err } user.AffCount++ - user.AffQuota += common.QuotaForInviter - user.AffHistoryQuota += common.QuotaForInviter + if rewardQuota > 0 { + user.AffQuota += rewardQuota + user.AffHistoryQuota += rewardQuota + } return DB.Save(user).Error } @@ -432,16 +442,18 @@ func (user *User) finishInsert(inviterId int) { if common.QuotaForNewUser > 0 { RecordLog(user.Id, LogTypeSystem, fmt.Sprintf("新用户注册赠送 %s", logger.LogQuota(common.QuotaForNewUser))) } - if inviterId != 0 && operation_setting.IsPaymentComplianceConfirmed() { - if common.QuotaForInvitee > 0 { + if inviterId != 0 { + inviterRewardQuota := 0 + complianceConfirmed := operation_setting.IsPaymentComplianceConfirmed() + if complianceConfirmed && common.QuotaForInvitee > 0 { _ = IncreaseUserQuota(user.Id, common.QuotaForInvitee, true) RecordLog(user.Id, LogTypeSystem, fmt.Sprintf("使用邀请码赠送 %s", logger.LogQuota(common.QuotaForInvitee))) } - if common.QuotaForInviter > 0 { - //_ = IncreaseUserQuota(inviterId, common.QuotaForInviter) + if complianceConfirmed && common.QuotaForInviter > 0 { RecordLog(inviterId, LogTypeSystem, fmt.Sprintf("邀请用户赠送 %s", logger.LogQuota(common.QuotaForInviter))) - _ = inviteUser(inviterId) + inviterRewardQuota = common.QuotaForInviter } + _ = inviteUser(inviterId, inviterRewardQuota) } } @@ -496,15 +508,18 @@ func (user *User) FinalizeOAuthUserCreation(inviterId int) { if common.QuotaForNewUser > 0 { RecordLog(user.Id, LogTypeSystem, fmt.Sprintf("新用户注册赠送 %s", logger.LogQuota(common.QuotaForNewUser))) } - if inviterId != 0 && operation_setting.IsPaymentComplianceConfirmed() { - if common.QuotaForInvitee > 0 { + if inviterId != 0 { + inviterRewardQuota := 0 + complianceConfirmed := operation_setting.IsPaymentComplianceConfirmed() + if complianceConfirmed && common.QuotaForInvitee > 0 { _ = IncreaseUserQuota(user.Id, common.QuotaForInvitee, true) RecordLog(user.Id, LogTypeSystem, fmt.Sprintf("使用邀请码赠送 %s", logger.LogQuota(common.QuotaForInvitee))) } - if common.QuotaForInviter > 0 { + if complianceConfirmed && common.QuotaForInviter > 0 { RecordLog(inviterId, LogTypeSystem, fmt.Sprintf("邀请用户赠送 %s", logger.LogQuota(common.QuotaForInviter))) - _ = inviteUser(inviterId) + inviterRewardQuota = common.QuotaForInviter } + _ = inviteUser(inviterId, inviterRewardQuota) } } @@ -1008,6 +1023,11 @@ func updateUserUsedQuotaAndRequestCount(id int, quota int, count int) { common.SysLog("failed to update user used quota and request count: " + err.Error()) return } + if quota > 0 { + if err := ReleaseEligibleAffiliateRebatesForInvitee(id); err != nil { + common.SysLog("failed to release affiliate rebates: " + err.Error()) + } + } //// 更新缓存 //if err := invalidateUserCache(id); err != nil { @@ -1029,6 +1049,12 @@ func updateUserQuotaUsedQuotaAndRequestCount(id int, quota int, usedQuota int, r ).Error if err != nil { common.SysLog("failed to batch update user quota, used quota and request count: " + err.Error()) + return + } + if usedQuota > 0 { + if err := ReleaseEligibleAffiliateRebatesForInvitee(id); err != nil { + common.SysLog("failed to release affiliate rebates: " + err.Error()) + } } } diff --git a/router/api-router.go b/router/api-router.go index efe2131dd3a2..a880045ec445 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -93,6 +93,8 @@ func SetApiRouter(router *gin.Engine) { selfRoute.POST("/passkey/verify/finish", controller.PasskeyVerifyFinish) selfRoute.DELETE("/passkey", controller.PasskeyDelete) selfRoute.GET("/aff", controller.GetAffCode) + selfRoute.GET("/aff_withdrawal/self", controller.GetAffiliateWithdrawals) + selfRoute.POST("/aff_withdrawal", controller.CreateAffiliateWithdrawal) selfRoute.GET("/topup/info", controller.GetTopUpInfo) selfRoute.GET("/topup/self", controller.GetUserTopUps) selfRoute.POST("/topup", middleware.CriticalRateLimit(), controller.TopUp) @@ -130,6 +132,8 @@ func SetApiRouter(router *gin.Engine) { adminRoute.GET("/", controller.GetAllUsers) adminRoute.GET("/topup", controller.GetAllTopUps) adminRoute.POST("/topup/complete", controller.AdminCompleteTopUp) + adminRoute.GET("/aff_withdrawal", controller.GetAllAffiliateWithdrawals) + adminRoute.POST("/aff_withdrawal/:id/process", controller.ProcessAffiliateWithdrawal) adminRoute.GET("/search", controller.SearchUsers) adminRoute.GET("/:id/oauth/bindings", controller.GetUserOAuthBindingsByAdmin) adminRoute.DELETE("/:id/oauth/bindings/:provider_id", controller.UnbindCustomOAuthByAdmin) diff --git a/setting/operation_setting/affiliate_setting.go b/setting/operation_setting/affiliate_setting.go new file mode 100644 index 000000000000..3dd8a7d628c2 --- /dev/null +++ b/setting/operation_setting/affiliate_setting.go @@ -0,0 +1,21 @@ +package operation_setting + +import "github.com/QuantumNous/new-api/setting/config" + +type AffiliateSetting struct { + Enabled bool `json:"enabled"` + RewardPercent float64 `json:"reward_percent"` + SettleAfterInviteeConsumed bool `json:"settle_after_invitee_consumed"` + RedemptionEnabled bool `json:"redemption_enabled"` + WithdrawEnabled bool `json:"withdraw_enabled"` +} + +var affiliateSetting = AffiliateSetting{} + +func init() { + config.GlobalConfig.Register("affiliate_setting", &affiliateSetting) +} + +func GetAffiliateSetting() *AffiliateSetting { + return &affiliateSetting +} diff --git a/web/default/src/features/system-settings/billing/index.tsx b/web/default/src/features/system-settings/billing/index.tsx index daad50668a92..8e60d5c9ca7e 100644 --- a/web/default/src/features/system-settings/billing/index.tsx +++ b/web/default/src/features/system-settings/billing/index.tsx @@ -32,6 +32,11 @@ const defaultBillingSettings: BillingSettings = { TopUpLink: '', 'general_setting.docs_link': '', 'quota_setting.enable_free_model_pre_consume': true, + 'affiliate_setting.enabled': false, + 'affiliate_setting.reward_percent': 0, + 'affiliate_setting.settle_after_invitee_consumed': false, + 'affiliate_setting.redemption_enabled': false, + 'affiliate_setting.withdraw_enabled': false, QuotaPerUnit: 500000, USDExchangeRate: 7, 'general_setting.quota_display_type': 'USD', diff --git a/web/default/src/features/system-settings/billing/section-registry.tsx b/web/default/src/features/system-settings/billing/section-registry.tsx index cc60947680e5..57f54e6e1afa 100644 --- a/web/default/src/features/system-settings/billing/section-registry.tsx +++ b/web/default/src/features/system-settings/billing/section-registry.tsx @@ -70,6 +70,14 @@ const BILLING_SECTIONS = [ enable_free_model_pre_consume: settings['quota_setting.enable_free_model_pre_consume'], }, + affiliate_setting: { + enabled: settings['affiliate_setting.enabled'], + reward_percent: settings['affiliate_setting.reward_percent'], + settle_after_invitee_consumed: + settings['affiliate_setting.settle_after_invitee_consumed'], + redemption_enabled: settings['affiliate_setting.redemption_enabled'], + withdraw_enabled: settings['affiliate_setting.withdraw_enabled'], + }, }} complianceConfirmed={ (settings['payment_setting.compliance_confirmed'] ?? false) && diff --git a/web/default/src/features/system-settings/general/quota-settings-section.tsx b/web/default/src/features/system-settings/general/quota-settings-section.tsx index 2b5e076307cc..8610b0b514c0 100644 --- a/web/default/src/features/system-settings/general/quota-settings-section.tsx +++ b/web/default/src/features/system-settings/general/quota-settings-section.tsx @@ -61,6 +61,13 @@ const quotaSchema = z.object({ quota_setting: z.object({ enable_free_model_pre_consume: z.boolean(), }), + affiliate_setting: z.object({ + enabled: z.boolean(), + reward_percent: z.coerce.number().min(0).max(100), + settle_after_invitee_consumed: z.boolean(), + redemption_enabled: z.boolean(), + withdraw_enabled: z.boolean(), + }), }) type QuotaFormValues = z.infer @@ -220,6 +227,139 @@ export function QuotaSettingsSection({ )} /> + + ( + + + {t('Referral Rebate')} + + {t( + 'When enabled, referrers earn a percentage of invited users paid top-ups.' + )} + + + + + + + )} + /> + + + ( + + {t('Referral Rebate Percent')} + + + + + {t( + 'Percentage of invited user top-ups credited as rewards' + )} + + + + )} + /> + + + ( + + + {t('Redemption Code Rebate')} + + {t( + 'When enabled, redemption-code top-ups also generate referral rebates.' + )} + + + + + + + )} + /> + + + + ( + + + {t('Settle After Consumption')} + + {t( + 'Hold rebate rewards until the invited user consumes the credited top-up quota.' + )} + + + + + + + )} + /> + + + + ( + + + {t('Referral Withdrawals')} + + {t( + 'Allow users to submit withdrawal requests for available referral rewards.' + )} + + + + + + + )} + /> + + = ROLE.ADMIN @@ -450,6 +452,116 @@ export function UsersMutateDrawer({ )} + {isUpdate && ( + +

+ {t('Referral Rebate Rule')} +

+ + ( + +
+ {t('Custom Referral Rule')} + + {t( + 'Override the global referral rebate rule for this user' + )} + +
+ + + +
+ )} + /> + + {affiliateRuleCustom && ( + <> + ( + +
+ {t('Referral Rebate')} + + {t('Enable rebate rewards for this user')} + +
+ + + +
+ )} + /> + + ( + + + {t('Referral Rebate Percent')} + + + + field.onChange( + event.target.value === '' + ? 0 + : event.currentTarget.valueAsNumber + ) + } + /> + + + + )} + /> + + ( + +
+ + {t('Settle After Consumption')} + + + {t( + 'Hold rebate rewards until invited users consume their credited top-up quota.' + )} + +
+ + + +
+ )} + /> + + )} +
+ )} + {canEditAdminPermissions && targetIsAdmin && permissionCatalog.resources.length > 0 && ( diff --git a/web/default/src/features/users/lib/user-form.ts b/web/default/src/features/users/lib/user-form.ts index bc6c7894acb3..92a7c81bbe96 100644 --- a/web/default/src/features/users/lib/user-form.ts +++ b/web/default/src/features/users/lib/user-form.ts @@ -41,6 +41,14 @@ export const userFormSchema = z.object({ quota_dollars: z.number().min(0).optional(), group: z.string().optional(), remark: z.string().optional(), + affiliate_rule: z + .object({ + custom: z.boolean(), + enabled: z.boolean(), + reward_percent: z.number().min(0).max(100), + settle_after_invitee_consumed: z.boolean(), + }) + .optional(), admin_permissions: z .record(z.string(), z.record(z.string(), z.boolean())) .optional(), @@ -60,6 +68,12 @@ export const USER_FORM_DEFAULT_VALUES: UserFormValues = { quota_dollars: 0, group: DEFAULT_GROUP, remark: '', + affiliate_rule: { + custom: false, + enabled: false, + reward_percent: 0, + settle_after_invitee_consumed: false, + }, // Filled against the backend catalog at render time; see UsersMutateDrawer. admin_permissions: {}, } @@ -101,6 +115,7 @@ export function transformFormDataToPayload( // For update: quota is adjusted atomically via /api/user/manage, not sent here payload.group = data.group payload.remark = data.remark || undefined + payload.affiliate_rule = data.affiliate_rule payload.id = userId } @@ -121,6 +136,12 @@ export function transformUserToFormDefaults(user: User): UserFormValues { quota_dollars: quotaUnitsToDollars(user.quota), group: user.group || DEFAULT_GROUP, remark: user.remark || '', + affiliate_rule: user.affiliate_rule ?? { + custom: false, + enabled: false, + reward_percent: 0, + settle_after_invitee_consumed: false, + }, admin_permissions: user.admin_permissions ?? {}, } } diff --git a/web/default/src/features/users/types.ts b/web/default/src/features/users/types.ts index a6f125144932..0e42f99669c5 100644 --- a/web/default/src/features/users/types.ts +++ b/web/default/src/features/users/types.ts @@ -59,6 +59,14 @@ export const userSchema = z.object({ last_login_at: z.number().optional(), DeletedAt: z.any().nullable().optional(), remark: z.string().optional(), + affiliate_rule: z + .object({ + custom: z.boolean(), + enabled: z.boolean(), + reward_percent: z.number(), + settle_after_invitee_consumed: z.boolean(), + }) + .optional(), admin_permissions: z .record(z.string(), z.record(z.string(), z.boolean())) .optional(), @@ -111,6 +119,12 @@ export interface UserFormData { quota?: number // Only used when updating user group?: string // Only used when updating user remark?: string // Only used when updating user + affiliate_rule?: { + custom: boolean + enabled: boolean + reward_percent: number + settle_after_invitee_consumed: boolean + } admin_permissions?: AdminPermissionMatrix } diff --git a/web/default/src/features/wallet/api.ts b/web/default/src/features/wallet/api.ts index 7cd0460a5eec..0755d4043d96 100644 --- a/web/default/src/features/wallet/api.ts +++ b/web/default/src/features/wallet/api.ts @@ -23,6 +23,7 @@ import type { PaymentRequest, AmountRequest, AffiliateTransferRequest, + AffiliateWithdrawalRequest, ApiResponse, TopupInfoResponse, RedemptionResponse, @@ -31,6 +32,7 @@ import type { StripePaymentResponse, AffiliateCodeResponse, AffiliateTransferResponse, + AffiliateWithdrawalResponse, BillingHistoryResponse, CompleteOrderRequest, CreemPaymentRequest, @@ -187,6 +189,16 @@ export async function transferAffiliateQuota( return res.data } +/** + * Submit affiliate withdrawal request + */ +export async function createAffiliateWithdrawal( + request: AffiliateWithdrawalRequest +): Promise { + const res = await api.post('/api/user/aff_withdrawal', request) + return res.data +} + /** * Get billing history for current user */ diff --git a/web/default/src/features/wallet/components/affiliate-rewards-card.tsx b/web/default/src/features/wallet/components/affiliate-rewards-card.tsx index b621e0bba3b8..029d0c267597 100644 --- a/web/default/src/features/wallet/components/affiliate-rewards-card.tsx +++ b/web/default/src/features/wallet/components/affiliate-rewards-card.tsx @@ -32,7 +32,9 @@ interface AffiliateRewardsCardProps { user: UserWalletData | null affiliateLink: string onTransfer: () => void + onWithdraw: () => void complianceConfirmed?: boolean + withdrawalEnabled?: boolean loading?: boolean } @@ -40,7 +42,9 @@ export function AffiliateRewardsCard({ user, affiliateLink, onTransfer, + onWithdraw, complianceConfirmed = true, + withdrawalEnabled = false, loading, }: AffiliateRewardsCardProps) { const { t } = useTranslation() @@ -74,15 +78,16 @@ export function AffiliateRewardsCard({

{t( - 'Earn rewards when your referrals add funds. Transfer accumulated rewards to your balance anytime.' + 'Earn rewards when your referrals add funds. Use available rewards after they settle.' )}

-
+
{[ - [t('Pending'), formatQuota(user?.aff_quota ?? 0)], + [t('Available'), formatQuota(user?.aff_quota ?? 0)], + [t('Pending'), formatQuota(user?.aff_pending_quota ?? 0)], [t('Total Earned'), formatQuota(user?.aff_history_quota ?? 0)], [t('Invites'), String(user?.aff_count ?? 0)], ].map(([label, value]) => ( @@ -97,7 +102,7 @@ export function AffiliateRewardsCard({ ))}
-
+
{hasRewards && ( - + <> + + {withdrawalEnabled ? ( + + ) : null} + )}
{!complianceConfirmed ? ( diff --git a/web/default/src/features/wallet/components/dialogs/affiliate-withdrawal-dialog.tsx b/web/default/src/features/wallet/components/dialogs/affiliate-withdrawal-dialog.tsx new file mode 100644 index 000000000000..ef8ef34b5e71 --- /dev/null +++ b/web/default/src/features/wallet/components/dialogs/affiliate-withdrawal-dialog.tsx @@ -0,0 +1,171 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { Loader2 } from 'lucide-react' +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' + +import { Dialog } from '@/components/dialog' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Textarea } from '@/components/ui/textarea' +import { formatQuota } from '@/lib/format' + +import { QUOTA_PER_DOLLAR } from '../../constants' +import type { AffiliateWithdrawalRequest } from '../../types' + +interface AffiliateWithdrawalDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + onConfirm: (request: AffiliateWithdrawalRequest) => Promise + availableQuota: number + withdrawing: boolean +} + +export function AffiliateWithdrawalDialog(props: AffiliateWithdrawalDialogProps) { + const { t } = useTranslation() + const [amount, setAmount] = useState(QUOTA_PER_DOLLAR) + const [paymentMethod, setPaymentMethod] = useState('') + const [account, setAccount] = useState('') + const [remark, setRemark] = useState('') + + useEffect(() => { + if (props.open) { + setAmount(Math.min(QUOTA_PER_DOLLAR, props.availableQuota)) + setPaymentMethod('') + setAccount('') + setRemark('') + } + }, [props.availableQuota, props.open]) + + const invalid = + amount <= 0 || + amount > props.availableQuota || + paymentMethod.trim() === '' || + account.trim() === '' + + const handleConfirm = async () => { + const success = await props.onConfirm({ + amount, + payment_method: paymentMethod.trim(), + account: account.trim(), + remark: remark.trim(), + }) + if (success) { + props.onOpenChange(false) + } + } + + return ( + + + + + } + > +
+
+ +
+ {formatQuota(props.availableQuota)} +
+
+ +
+ + setAmount(Number(event.target.value))} + min={1} + max={props.availableQuota} + step={QUOTA_PER_DOLLAR} + className='font-mono' + /> +
+ +
+ + setPaymentMethod(event.target.value)} + placeholder={t('Bank transfer, PayPal, Alipay...')} + /> +
+ +
+ + setAccount(event.target.value)} + placeholder={t('Account, email, or wallet address')} + /> +
+ +
+ +