-
Notifications
You must be signed in to change notification settings - Fork 11.2k
✨ feat: add subscription billing system #2808
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
c6c12d3
009910b
348ae6d
a60783e
697cbbf
ecf50b7
5707ee3
ffebb35
41489fc
a0c23a0
354da6e
28c5feb
2297af7
cf67af3
b92a4ee
6300c31
c22ca9c
96caec1
f0e60df
de90e11
e8bd2e0
a74cc93
16349c9
120256a
1fba3c0
4057eed
34e5720
70a8b30
ef44a34
b942d4e
6b694c9
414f86f
49ac355
2cdc37f
0486a5d
3d177f3
1e2c039
11eef1c
f521a43
732484c
f578aa8
4e9c5bb
c1061b2
e48b74f
58afec3
b8b4051
47012e8
e967094
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,367 @@ | ||
| package controller | ||
|
|
||
| import ( | ||
| "strconv" | ||
| "strings" | ||
|
|
||
| "github.com/QuantumNous/new-api/common" | ||
| "github.com/QuantumNous/new-api/model" | ||
| "github.com/QuantumNous/new-api/setting/ratio_setting" | ||
| "github.com/gin-gonic/gin" | ||
| "gorm.io/gorm" | ||
| ) | ||
|
|
||
| // ---- Shared types ---- | ||
|
|
||
| type SubscriptionPlanDTO struct { | ||
| Plan model.SubscriptionPlan `json:"plan"` | ||
| } | ||
|
|
||
| type BillingPreferenceRequest struct { | ||
| BillingPreference string `json:"billing_preference"` | ||
| } | ||
|
|
||
| // ---- User APIs ---- | ||
|
|
||
| func GetSubscriptionPlans(c *gin.Context) { | ||
| var plans []model.SubscriptionPlan | ||
| if err := model.DB.Where("enabled = ?", true).Order("sort_order desc, id desc").Find(&plans).Error; err != nil { | ||
| common.ApiError(c, err) | ||
| return | ||
| } | ||
| result := make([]SubscriptionPlanDTO, 0, len(plans)) | ||
| for _, p := range plans { | ||
| result = append(result, SubscriptionPlanDTO{ | ||
| Plan: p, | ||
| }) | ||
| } | ||
| common.ApiSuccess(c, result) | ||
| } | ||
|
|
||
| func GetSubscriptionSelf(c *gin.Context) { | ||
| userId := c.GetInt("id") | ||
| settingMap, _ := model.GetUserSetting(userId, false) | ||
| pref := common.NormalizeBillingPreference(settingMap.BillingPreference) | ||
|
|
||
| // Get all subscriptions (including expired) | ||
| allSubscriptions, err := model.GetAllUserSubscriptions(userId) | ||
| if err != nil { | ||
| allSubscriptions = []model.SubscriptionSummary{} | ||
| } | ||
|
|
||
| // Get active subscriptions for backward compatibility | ||
| activeSubscriptions, err := model.GetAllActiveUserSubscriptions(userId) | ||
| if err != nil { | ||
| activeSubscriptions = []model.SubscriptionSummary{} | ||
| } | ||
|
|
||
| common.ApiSuccess(c, gin.H{ | ||
| "billing_preference": pref, | ||
| "subscriptions": activeSubscriptions, // all active subscriptions | ||
| "all_subscriptions": allSubscriptions, // all subscriptions including expired | ||
| }) | ||
| } | ||
|
|
||
| func UpdateSubscriptionPreference(c *gin.Context) { | ||
| userId := c.GetInt("id") | ||
| var req BillingPreferenceRequest | ||
| if err := c.ShouldBindJSON(&req); err != nil { | ||
| common.ApiErrorMsg(c, "参数错误") | ||
| return | ||
| } | ||
| pref := common.NormalizeBillingPreference(req.BillingPreference) | ||
|
|
||
| user, err := model.GetUserById(userId, true) | ||
| if err != nil { | ||
| common.ApiError(c, err) | ||
| return | ||
| } | ||
| current := user.GetSetting() | ||
| current.BillingPreference = pref | ||
| user.SetSetting(current) | ||
| if err := user.Update(false); err != nil { | ||
| common.ApiError(c, err) | ||
| return | ||
| } | ||
| common.ApiSuccess(c, gin.H{"billing_preference": pref}) | ||
| } | ||
|
|
||
| // ---- Admin APIs ---- | ||
|
|
||
| func AdminListSubscriptionPlans(c *gin.Context) { | ||
| var plans []model.SubscriptionPlan | ||
| if err := model.DB.Order("sort_order desc, id desc").Find(&plans).Error; err != nil { | ||
| common.ApiError(c, err) | ||
| return | ||
| } | ||
| result := make([]SubscriptionPlanDTO, 0, len(plans)) | ||
| for _, p := range plans { | ||
| result = append(result, SubscriptionPlanDTO{ | ||
| Plan: p, | ||
| }) | ||
| } | ||
| common.ApiSuccess(c, result) | ||
| } | ||
|
|
||
| type AdminUpsertSubscriptionPlanRequest struct { | ||
| Plan model.SubscriptionPlan `json:"plan"` | ||
| } | ||
|
|
||
| func AdminCreateSubscriptionPlan(c *gin.Context) { | ||
| var req AdminUpsertSubscriptionPlanRequest | ||
| if err := c.ShouldBindJSON(&req); err != nil { | ||
| common.ApiErrorMsg(c, "参数错误") | ||
| return | ||
| } | ||
| req.Plan.Id = 0 | ||
| if strings.TrimSpace(req.Plan.Title) == "" { | ||
| common.ApiErrorMsg(c, "套餐标题不能为空") | ||
| return | ||
| } | ||
| if req.Plan.Currency == "" { | ||
| req.Plan.Currency = "USD" | ||
| } | ||
| req.Plan.Currency = "USD" | ||
| if req.Plan.DurationUnit == "" { | ||
| req.Plan.DurationUnit = model.SubscriptionDurationMonth | ||
| } | ||
| if req.Plan.DurationValue <= 0 && req.Plan.DurationUnit != model.SubscriptionDurationCustom { | ||
| req.Plan.DurationValue = 1 | ||
| } | ||
| if req.Plan.MaxPurchasePerUser < 0 { | ||
| common.ApiErrorMsg(c, "购买上限不能为负数") | ||
| return | ||
| } | ||
| if req.Plan.TotalAmount < 0 { | ||
| common.ApiErrorMsg(c, "总额度不能为负数") | ||
| return | ||
| } | ||
| req.Plan.UpgradeGroup = strings.TrimSpace(req.Plan.UpgradeGroup) | ||
| if req.Plan.UpgradeGroup != "" { | ||
| if _, ok := ratio_setting.GetGroupRatioCopy()[req.Plan.UpgradeGroup]; !ok { | ||
| common.ApiErrorMsg(c, "升级分组不存在") | ||
| return | ||
| } | ||
| } | ||
| req.Plan.QuotaResetPeriod = model.NormalizeResetPeriod(req.Plan.QuotaResetPeriod) | ||
| if req.Plan.QuotaResetPeriod == model.SubscriptionResetCustom && req.Plan.QuotaResetCustomSeconds <= 0 { | ||
| common.ApiErrorMsg(c, "自定义重置周期需大于0秒") | ||
| return | ||
| } | ||
| err := model.DB.Create(&req.Plan).Error | ||
| if err != nil { | ||
| common.ApiError(c, err) | ||
| return | ||
| } | ||
| model.InvalidateSubscriptionPlanCache(req.Plan.Id) | ||
| common.ApiSuccess(c, req.Plan) | ||
| } | ||
|
Comment on lines
+110
to
+158
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Validate that 🧾 Suggested fix if req.Plan.MaxPurchasePerUser < 0 {
common.ApiErrorMsg(c, "购买上限不能为负数")
return
}
+ if req.Plan.PriceAmount < 0 {
+ common.ApiErrorMsg(c, "金额不能为负数")
+ return
+ }
if req.Plan.TotalAmount < 0 {
common.ApiErrorMsg(c, "总额度不能为负数")
return
}🤖 Prompt for AI Agents |
||
|
|
||
| func AdminUpdateSubscriptionPlan(c *gin.Context) { | ||
| id, _ := strconv.Atoi(c.Param("id")) | ||
| if id <= 0 { | ||
| common.ApiErrorMsg(c, "无效的ID") | ||
| return | ||
| } | ||
| var req AdminUpsertSubscriptionPlanRequest | ||
| if err := c.ShouldBindJSON(&req); err != nil { | ||
| common.ApiErrorMsg(c, "参数错误") | ||
| return | ||
| } | ||
| if strings.TrimSpace(req.Plan.Title) == "" { | ||
| common.ApiErrorMsg(c, "套餐标题不能为空") | ||
| return | ||
| } | ||
| req.Plan.Id = id | ||
| if req.Plan.Currency == "" { | ||
| req.Plan.Currency = "USD" | ||
| } | ||
| req.Plan.Currency = "USD" | ||
| if req.Plan.DurationUnit == "" { | ||
| req.Plan.DurationUnit = model.SubscriptionDurationMonth | ||
| } | ||
| if req.Plan.DurationValue <= 0 && req.Plan.DurationUnit != model.SubscriptionDurationCustom { | ||
| req.Plan.DurationValue = 1 | ||
| } | ||
| if req.Plan.MaxPurchasePerUser < 0 { | ||
| common.ApiErrorMsg(c, "购买上限不能为负数") | ||
| return | ||
| } | ||
| if req.Plan.TotalAmount < 0 { | ||
| common.ApiErrorMsg(c, "总额度不能为负数") | ||
| return | ||
| } | ||
| req.Plan.UpgradeGroup = strings.TrimSpace(req.Plan.UpgradeGroup) | ||
| if req.Plan.UpgradeGroup != "" { | ||
| if _, ok := ratio_setting.GetGroupRatioCopy()[req.Plan.UpgradeGroup]; !ok { | ||
| common.ApiErrorMsg(c, "升级分组不存在") | ||
| return | ||
| } | ||
| } | ||
| req.Plan.QuotaResetPeriod = model.NormalizeResetPeriod(req.Plan.QuotaResetPeriod) | ||
| if req.Plan.QuotaResetPeriod == model.SubscriptionResetCustom && req.Plan.QuotaResetCustomSeconds <= 0 { | ||
| common.ApiErrorMsg(c, "自定义重置周期需大于0秒") | ||
| return | ||
| } | ||
|
|
||
| err := model.DB.Transaction(func(tx *gorm.DB) error { | ||
| // update plan (allow zero values updates with map) | ||
| updateMap := map[string]interface{}{ | ||
| "title": req.Plan.Title, | ||
| "subtitle": req.Plan.Subtitle, | ||
| "price_amount": req.Plan.PriceAmount, | ||
| "currency": req.Plan.Currency, | ||
| "duration_unit": req.Plan.DurationUnit, | ||
| "duration_value": req.Plan.DurationValue, | ||
| "custom_seconds": req.Plan.CustomSeconds, | ||
| "enabled": req.Plan.Enabled, | ||
| "sort_order": req.Plan.SortOrder, | ||
| "stripe_price_id": req.Plan.StripePriceId, | ||
| "creem_product_id": req.Plan.CreemProductId, | ||
| "max_purchase_per_user": req.Plan.MaxPurchasePerUser, | ||
| "total_amount": req.Plan.TotalAmount, | ||
| "upgrade_group": req.Plan.UpgradeGroup, | ||
| "quota_reset_period": req.Plan.QuotaResetPeriod, | ||
| "quota_reset_custom_seconds": req.Plan.QuotaResetCustomSeconds, | ||
| "updated_at": common.GetTimestamp(), | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| if err := tx.Model(&model.SubscriptionPlan{}).Where("id = ?", id).Updates(updateMap).Error; err != nil { | ||
| return err | ||
| } | ||
| return nil | ||
| }) | ||
| if err != nil { | ||
| common.ApiError(c, err) | ||
| return | ||
| } | ||
| model.InvalidateSubscriptionPlanCache(id) | ||
| common.ApiSuccess(c, nil) | ||
| } | ||
|
Comment on lines
+160
to
+239
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Validate that 🧾 Suggested fix if req.Plan.MaxPurchasePerUser < 0 {
common.ApiErrorMsg(c, "购买上限不能为负数")
return
}
+ if req.Plan.PriceAmount < 0 {
+ common.ApiErrorMsg(c, "金额不能为负数")
+ return
+ }
if req.Plan.TotalAmount < 0 {
common.ApiErrorMsg(c, "总额度不能为负数")
return
}🤖 Prompt for AI Agents |
||
|
|
||
| type AdminUpdateSubscriptionPlanStatusRequest struct { | ||
| Enabled *bool `json:"enabled"` | ||
| } | ||
|
|
||
| func AdminUpdateSubscriptionPlanStatus(c *gin.Context) { | ||
| id, _ := strconv.Atoi(c.Param("id")) | ||
| if id <= 0 { | ||
| common.ApiErrorMsg(c, "无效的ID") | ||
| return | ||
| } | ||
| var req AdminUpdateSubscriptionPlanStatusRequest | ||
| if err := c.ShouldBindJSON(&req); err != nil || req.Enabled == nil { | ||
| common.ApiErrorMsg(c, "参数错误") | ||
| return | ||
| } | ||
| if err := model.DB.Model(&model.SubscriptionPlan{}).Where("id = ?", id).Update("enabled", *req.Enabled).Error; err != nil { | ||
| common.ApiError(c, err) | ||
| return | ||
| } | ||
| model.InvalidateSubscriptionPlanCache(id) | ||
| common.ApiSuccess(c, nil) | ||
| } | ||
|
|
||
| type AdminBindSubscriptionRequest struct { | ||
| UserId int `json:"user_id"` | ||
| PlanId int `json:"plan_id"` | ||
| } | ||
|
|
||
| func AdminBindSubscription(c *gin.Context) { | ||
| var req AdminBindSubscriptionRequest | ||
| if err := c.ShouldBindJSON(&req); err != nil || req.UserId <= 0 || req.PlanId <= 0 { | ||
| common.ApiErrorMsg(c, "参数错误") | ||
| return | ||
| } | ||
| msg, err := model.AdminBindSubscription(req.UserId, req.PlanId, "") | ||
| if err != nil { | ||
| common.ApiError(c, err) | ||
| return | ||
| } | ||
| if msg != "" { | ||
| common.ApiSuccess(c, gin.H{"message": msg}) | ||
| return | ||
| } | ||
| common.ApiSuccess(c, nil) | ||
| } | ||
|
|
||
| // ---- Admin: user subscription management ---- | ||
|
|
||
| func AdminListUserSubscriptions(c *gin.Context) { | ||
| userId, _ := strconv.Atoi(c.Param("id")) | ||
| if userId <= 0 { | ||
| common.ApiErrorMsg(c, "无效的用户ID") | ||
| return | ||
| } | ||
| subs, err := model.GetAllUserSubscriptions(userId) | ||
| if err != nil { | ||
| common.ApiError(c, err) | ||
| return | ||
| } | ||
| common.ApiSuccess(c, subs) | ||
| } | ||
|
|
||
| type AdminCreateUserSubscriptionRequest struct { | ||
| PlanId int `json:"plan_id"` | ||
| } | ||
|
|
||
| // AdminCreateUserSubscription creates a new user subscription from a plan (no payment). | ||
| func AdminCreateUserSubscription(c *gin.Context) { | ||
| userId, _ := strconv.Atoi(c.Param("id")) | ||
| if userId <= 0 { | ||
| common.ApiErrorMsg(c, "无效的用户ID") | ||
| return | ||
| } | ||
| var req AdminCreateUserSubscriptionRequest | ||
| if err := c.ShouldBindJSON(&req); err != nil || req.PlanId <= 0 { | ||
| common.ApiErrorMsg(c, "参数错误") | ||
| return | ||
| } | ||
| msg, err := model.AdminBindSubscription(userId, req.PlanId, "") | ||
| if err != nil { | ||
| common.ApiError(c, err) | ||
| return | ||
| } | ||
| if msg != "" { | ||
| common.ApiSuccess(c, gin.H{"message": msg}) | ||
| return | ||
| } | ||
| common.ApiSuccess(c, nil) | ||
| } | ||
|
|
||
| // AdminInvalidateUserSubscription cancels a user subscription immediately. | ||
| func AdminInvalidateUserSubscription(c *gin.Context) { | ||
| subId, _ := strconv.Atoi(c.Param("id")) | ||
| if subId <= 0 { | ||
| common.ApiErrorMsg(c, "无效的订阅ID") | ||
| return | ||
| } | ||
| msg, err := model.AdminInvalidateUserSubscription(subId) | ||
| if err != nil { | ||
| common.ApiError(c, err) | ||
| return | ||
| } | ||
| if msg != "" { | ||
| common.ApiSuccess(c, gin.H{"message": msg}) | ||
| return | ||
| } | ||
| common.ApiSuccess(c, nil) | ||
| } | ||
|
|
||
| // AdminDeleteUserSubscription hard-deletes a user subscription. | ||
| func AdminDeleteUserSubscription(c *gin.Context) { | ||
| subId, _ := strconv.Atoi(c.Param("id")) | ||
| if subId <= 0 { | ||
| common.ApiErrorMsg(c, "无效的订阅ID") | ||
| return | ||
| } | ||
| msg, err := model.AdminDeleteUserSubscription(subId) | ||
| if err != nil { | ||
| common.ApiError(c, err) | ||
| return | ||
| } | ||
| if msg != "" { | ||
| common.ApiSuccess(c, gin.H{"message": msg}) | ||
| return | ||
| } | ||
| common.ApiSuccess(c, nil) | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Don’t swallow DB errors when loading subscriptions.
Returning empty arrays on errors makes outages look like “no subscriptions.” Prefer surfacing the error (or at least logging) to avoid misleading UI.
💡 Suggested fix
🤖 Prompt for AI Agents