Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
c6c12d3
ci: create docker automation
t0ng7u Jan 29, 2026
009910b
✨ feat: add subscription billing system with admin management and use…
t0ng7u Jan 29, 2026
348ae6d
✨ feat(admin): add user subscription management and refine UI/pagination
t0ng7u Jan 30, 2026
a60783e
✨ feat(admin): streamline subscription plan benefits editor with bulk…
t0ng7u Jan 30, 2026
697cbbf
✨ fix(subscription): finalize payments, log billing, and clean up dea…
t0ng7u Jan 30, 2026
ecf50b7
🎨 style: format all code with gofmt and lint:fix
t0ng7u Jan 30, 2026
5707ee3
✨ feat(subscription): add quota reset periods and admin configuration
t0ng7u Jan 30, 2026
ffebb35
✨ feat(subscription): harden subscription billing with resets, idempo…
t0ng7u Jan 30, 2026
41489fc
✨ feat(subscription): cache plan lookups and stabilize pre-consume
t0ng7u Jan 30, 2026
a0c23a0
🐛 fix(subscription): avoid pre-consume lookup noise
t0ng7u Jan 30, 2026
354da6e
🔧 ci: Change workflow trigger to sub branch
t0ng7u Jan 31, 2026
28c5feb
💸 chore: Align subscription pricing display with global currency sett…
t0ng7u Jan 31, 2026
2297af7
🔧 chore: Unify subscription plan status toggle with PATCH endpoint
t0ng7u Jan 31, 2026
cf67af3
✨ feat: Add subscription limits and UI tags consistency
t0ng7u Jan 31, 2026
b92a4ee
🎨 style: tag color to white
t0ng7u Jan 31, 2026
6300c31
🚀 refactor: Simplify subscription quota to total amount model
t0ng7u Jan 31, 2026
c22ca9c
🚀 chore: Remove duplicate subscription usage percentage display
t0ng7u Jan 31, 2026
96caec1
✨ feat: Add subscription upgrade group with auto downgrade
t0ng7u Jan 31, 2026
f0e60df
✨ feat: Update subscription purchase modal display
t0ng7u Jan 31, 2026
de90e11
✨ feat: Extract quota conversion helpers to shared utils
t0ng7u Feb 1, 2026
e8bd2e0
✨ chore: Add upgrade group guidance in subscription editor
t0ng7u Feb 1, 2026
a74cc93
🔧 chore: remove unused Creem settings state
t0ng7u Feb 2, 2026
16349c9
Merge remote-tracking branch 'newapi/main' into sub
t0ng7u Feb 2, 2026
120256a
🚀 chore: Remove useless action
t0ng7u Feb 2, 2026
1fba3c0
✨ Add full i18n coverage for subscription-related UI across locales
t0ng7u Feb 2, 2026
4057eed
Merge branch 'sub' into feature/subscription
t0ng7u Feb 2, 2026
34e5720
✨ feat: harden subscription billing and improve UI consistency
t0ng7u Feb 2, 2026
70a8b30
Merge branch 'sub' into feature/subscription
t0ng7u Feb 2, 2026
ef44a34
🔧 fix: make epay webhook and return flow subscription-aware
t0ng7u Feb 2, 2026
b942d4e
Merge branch 'sub' into feature/subscription
t0ng7u Feb 2, 2026
6b694c9
🚦 fix: guard epay return success on order completion
t0ng7u Feb 2, 2026
414f86f
Merge branch 'sub' into feature/subscription
t0ng7u Feb 2, 2026
49ac355
🔧 fix: normalize epay error handling and webhook retries
t0ng7u Feb 2, 2026
2cdc37f
Merge branch 'sub' into feature/subscription
t0ng7u Feb 2, 2026
0486a5d
🧾 fix: persist epay orders before purchase
t0ng7u Feb 2, 2026
3d177f3
Merge branch 'sub' into feature/subscription
t0ng7u Feb 2, 2026
1e2c039
Merge remote-tracking branch 'newapi/main' into sub
t0ng7u Feb 2, 2026
11eef1c
Merge branch 'sub' into feature/subscription
t0ng7u Feb 2, 2026
f521a43
🔧 fix: harden epay callbacks and billing fallbacks
t0ng7u Feb 2, 2026
732484c
Merge branch 'sub' into feature/subscription
t0ng7u Feb 2, 2026
f578aa8
🔧 fix: harden billing flow and sidebar settings
t0ng7u Feb 2, 2026
4e9c5bb
Merge branch 'sub' into feature/subscription
t0ng7u Feb 2, 2026
c1061b2
🛡️ fix: fail fast on epay form parse errors
t0ng7u Feb 2, 2026
e48b74f
Merge branch 'sub' into feature/subscription
t0ng7u Feb 2, 2026
58afec3
✨ fix: refine Japanese subscription status labels
t0ng7u Feb 2, 2026
b8b4051
Merge branch 'sub' into feature/subscription
t0ng7u Feb 2, 2026
47012e8
✅ fix: standardize epay success response schema
t0ng7u Feb 2, 2026
e967094
Merge branch 'sub' into feature/subscription
t0ng7u Feb 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions common/str.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,16 @@ func GetJsonString(data any) string {
return string(b)
}

// NormalizeBillingPreference clamps the billing preference to valid values.
func NormalizeBillingPreference(pref string) string {
switch strings.TrimSpace(pref) {
case "subscription_first", "wallet_first", "subscription_only", "wallet_only":
return strings.TrimSpace(pref)
default:
return "subscription_first"
}
}

// MaskEmail masks a user email to prevent PII leakage in logs
// Returns "***masked***" if email is empty, otherwise shows only the domain part
func MaskEmail(email string) string {
Expand Down
2 changes: 1 addition & 1 deletion controller/relay.go
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) {
if priceData.FreeModel {
logger.LogInfo(c, fmt.Sprintf("模型 %s 免费,跳过预扣费", relayInfo.OriginModelName))
} else {
newAPIError = service.PreConsumeQuota(c, priceData.QuotaToPreConsume, relayInfo)
newAPIError = service.PreConsumeBilling(c, priceData.QuotaToPreConsume, relayInfo)
if newAPIError != nil {
return
}
Expand Down
367 changes: 367 additions & 0 deletions controller/subscription.go
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
})
}
Comment on lines +41 to +63

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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
-	allSubscriptions, err := model.GetAllUserSubscriptions(userId)
-	if err != nil {
-		allSubscriptions = []model.SubscriptionSummary{}
-	}
+	allSubscriptions, err := model.GetAllUserSubscriptions(userId)
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}

-	activeSubscriptions, err := model.GetAllActiveUserSubscriptions(userId)
-	if err != nil {
-		activeSubscriptions = []model.SubscriptionSummary{}
-	}
+	activeSubscriptions, err := model.GetAllActiveUserSubscriptions(userId)
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
🤖 Prompt for AI Agents
In `@controller/subscription.go` around lines 41 - 63, GetSubscriptionSelf is
swallowing DB errors from model.GetAllUserSubscriptions and
model.GetAllActiveUserSubscriptions by returning empty slices; instead, when
those calls return err capture and log the error (include err and userId) and
return an error response to the client (e.g., via common.ApiError or appropriate
5xx response) rather than silently returning empty arrays so the UI isn't
misled; update the error branches for both GetAllUserSubscriptions and
GetAllActiveUserSubscriptions to log the error and short-circuit with an error
response referencing GetSubscriptionSelf.


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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Validate that price_amount is non‑negative.
Negative prices can create invalid or crediting orders.

🧾 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
In `@controller/subscription.go` around lines 110 - 158, The handler
AdminCreateSubscriptionPlan currently doesn't validate the plan price; add a
check that the plan's price_amount field is non‑negative (e.g., validate
req.Plan.PriceAmount >= 0) before persisting: if negative, return an API error
(same pattern as other checks) and abort; place this validation in
AdminCreateSubscriptionPlan just before the DB.Create call so invalid plans are
rejected and do not create crediting orders.


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(),
}
Comment thread
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Validate that price_amount is non‑negative on update as well.
Keeps stored pricing consistent and safe.

🧾 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
In `@controller/subscription.go` around lines 160 - 239,
AdminUpdateSubscriptionPlan currently doesn't validate req.Plan.PriceAmount on
updates; add a guard before starting the DB transaction to reject negative
prices. Specifically, in AdminUpdateSubscriptionPlan check if
req.Plan.PriceAmount < 0 and call common.ApiErrorMsg(c, "价格不能为负数") (or similar)
and return; then proceed to build updateMap and
tx.Model(&model.SubscriptionPlan{}).Where("id = ?", id).Updates(updateMap) as
before so negative values never get persisted.


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)
}
Loading