mirror of
https://github.com/labring/sealos.git
synced 2026-09-21 05:44:43 +08:00
* move to admin workspace subscription * support extend subscription expiration time
3359 lines
112 KiB
Go
3359 lines
112 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
"github.com/labring/sealos/controllers/pkg/database/cockroach"
|
|
"github.com/labring/sealos/controllers/pkg/resources"
|
|
"github.com/labring/sealos/controllers/pkg/types"
|
|
usernotify "github.com/labring/sealos/controllers/pkg/user_notify"
|
|
"github.com/labring/sealos/service/account/dao"
|
|
"github.com/labring/sealos/service/account/helper"
|
|
services "github.com/labring/sealos/service/pkg/pay"
|
|
gonanoid "github.com/matoous/go-nanoid/v2"
|
|
"github.com/sirupsen/logrus"
|
|
"github.com/stripe/stripe-go/v82"
|
|
"gorm.io/gorm"
|
|
corev1 "k8s.io/api/core/v1"
|
|
k8serrors "k8s.io/apimachinery/pkg/api/errors"
|
|
types2 "k8s.io/apimachinery/pkg/types"
|
|
"k8s.io/client-go/util/retry"
|
|
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
|
|
)
|
|
|
|
// GetWorkspaceSubscriptionInfo
|
|
// @Summary Get workspace subscription info
|
|
// @Description Get workspace subscription info
|
|
// @Tags WorkspaceSubscription
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param req body WorkspaceSubscriptionInfoReq true "WorkspaceSubscriptionInfoReq"
|
|
// @Success 200 {object} WorkspaceSubscriptionInfoResp
|
|
// @Router /payment/v1alpha1/workspace-subscription/info [post]
|
|
func GetWorkspaceSubscriptionInfo(c *gin.Context) {
|
|
req, err := helper.ParseWorkspaceSubscriptionInfoReq(c)
|
|
if err != nil {
|
|
c.JSON(
|
|
http.StatusBadRequest,
|
|
helper.ErrorMessage{Error: fmt.Sprintf("failed to parse request: %v", err)},
|
|
)
|
|
return
|
|
}
|
|
if err := authenticateWorkspaceSubscriptionRequest(c, req, false); err != nil {
|
|
c.JSON(
|
|
http.StatusUnauthorized,
|
|
helper.ErrorMessage{Error: fmt.Sprintf("authenticate error : %v", err)},
|
|
)
|
|
return
|
|
}
|
|
|
|
subscription, err := dao.DBClient.GetWorkspaceSubscription(req.Workspace, req.RegionDomain)
|
|
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
c.JSON(
|
|
http.StatusInternalServerError,
|
|
helper.ErrorMessage{
|
|
Error: fmt.Sprintf("failed to get workspace subscription info: %v", err),
|
|
},
|
|
)
|
|
return
|
|
}
|
|
const (
|
|
WorkspaceTypeSubscription = "SUBSCRIPTION"
|
|
WorkspaceTypePAYG = "PAYG"
|
|
)
|
|
workspaceSubInfo := struct {
|
|
*types.WorkspaceSubscription
|
|
Type string `json:"type"`
|
|
}{
|
|
WorkspaceSubscription: subscription,
|
|
Type: WorkspaceTypeSubscription,
|
|
}
|
|
|
|
if subscription == nil {
|
|
workspaceSubInfo.Type = WorkspaceTypePAYG
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"subscription": workspaceSubInfo,
|
|
})
|
|
}
|
|
|
|
// DeleteWorkspaceSubscription
|
|
func DeleteWorkspaceSubscription(c *gin.Context) {
|
|
req, err := helper.ParseWorkspaceSubscriptionInfoReq(c)
|
|
if err != nil {
|
|
c.JSON(
|
|
http.StatusBadRequest,
|
|
helper.ErrorMessage{Error: fmt.Sprintf("failed to parse request: %v", err)},
|
|
)
|
|
return
|
|
}
|
|
if err := authenticateWorkspaceSubscriptionRequest(c, req, true); err != nil {
|
|
c.JSON(
|
|
http.StatusUnauthorized,
|
|
helper.ErrorMessage{Error: fmt.Sprintf("authenticate error : %v", err)},
|
|
)
|
|
return
|
|
}
|
|
|
|
subscription, err := dao.DBClient.GetWorkspaceSubscription(req.Workspace, req.RegionDomain)
|
|
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
c.JSON(
|
|
http.StatusInternalServerError,
|
|
helper.ErrorMessage{
|
|
Error: fmt.Sprintf("failed to get workspace subscription info: %v", err),
|
|
},
|
|
)
|
|
return
|
|
}
|
|
if subscription == nil {
|
|
c.JSON(http.StatusOK, gin.H{})
|
|
return
|
|
}
|
|
if subscription.Status == types.SubscriptionStatusDeleted {
|
|
c.JSON(http.StatusOK, gin.H{})
|
|
return
|
|
}
|
|
err = dao.DBClient.GlobalTransactionHandler(func(tx *gorm.DB) error {
|
|
// TODO 更新订阅状态
|
|
needCancel := subscription.PayStatus == types.SubscriptionPayStatusPaid
|
|
subscription.Status = types.SubscriptionStatusDeleted
|
|
subscription.PayStatus = types.SubscriptionPayStatusCanceled
|
|
subscription.CancelAt = time.Now().UTC()
|
|
tx.Save(&subscription)
|
|
// TODO 创建交易记录
|
|
transaction := types.WorkspaceSubscriptionTransaction{
|
|
ID: uuid.New(),
|
|
From: types.TransactionFromUser,
|
|
Workspace: req.Workspace,
|
|
RegionDomain: req.RegionDomain,
|
|
UserUID: req.UserUID,
|
|
OldPlanName: subscription.PlanName,
|
|
OldPlanStatus: subscription.Status,
|
|
NewPlanName: subscription.PlanName,
|
|
Operator: types.SubscriptionTransactionTypeDeleted,
|
|
StartAt: time.Now().UTC(),
|
|
CreatedAt: time.Now().UTC(),
|
|
Status: types.SubscriptionTransactionStatusCompleted,
|
|
PayStatus: types.SubscriptionPayStatusNoNeed,
|
|
}
|
|
if subscription.Stripe != nil && subscription.Stripe.SubscriptionID != "" {
|
|
transaction.StatusDesc = "Canceled by user with stripe: " + subscription.Stripe.SubscriptionID
|
|
}
|
|
if err = tx.Create(&transaction).Error; err != nil {
|
|
return err
|
|
}
|
|
if needCancel {
|
|
if subscription.PayMethod == types.PaymentMethodStripe &&
|
|
subscription.Stripe.SubscriptionID != "" {
|
|
sub, err := services.StripeServiceInstance.CancelSubscription(
|
|
subscription.Stripe.SubscriptionID)
|
|
if err != nil {
|
|
return fmt.Errorf(
|
|
"failed to cancel Stripe subscription %s: %w",
|
|
subscription.Stripe.SubscriptionID,
|
|
err,
|
|
)
|
|
}
|
|
if sub == nil {
|
|
return errors.New("stripe subscription cancel failed with nil subscription")
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
dao.Logger.Errorf("failed to delete workspace subscription info: %v", err)
|
|
c.JSON(
|
|
http.StatusInternalServerError,
|
|
helper.ErrorMessage{
|
|
Error: fmt.Sprintf("failed to delete workspace subscription: %v", err),
|
|
},
|
|
)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{})
|
|
}
|
|
|
|
func DeleteAccount(c *gin.Context) {
|
|
req := &helper.AuthBase{}
|
|
if err := authenticateRequest(c, req); err != nil {
|
|
c.JSON(
|
|
http.StatusUnauthorized,
|
|
helper.ErrorMessage{Error: fmt.Sprintf("authenticate error : %v", err)},
|
|
)
|
|
return
|
|
}
|
|
|
|
// 获取用户的所有工作空间订阅
|
|
subList, err := dao.DBClient.ListWorkspaceSubscription(req.UserUID)
|
|
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
c.JSON(
|
|
http.StatusInternalServerError,
|
|
helper.ErrorMessage{
|
|
Error: fmt.Sprintf("failed to get workspace subscription list: %v", err),
|
|
},
|
|
)
|
|
return
|
|
}
|
|
|
|
if len(subList) == 0 {
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"message": "No active workspace subscriptions found",
|
|
"success": true,
|
|
})
|
|
return
|
|
}
|
|
|
|
// 检查是否已经存在待处理的删除事务
|
|
lastTransactions, err := dao.DBClient.GetAllUnprocessedWorkspaceSubscriptionTransaction(
|
|
req.UserUID,
|
|
)
|
|
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
c.JSON(
|
|
http.StatusInternalServerError,
|
|
helper.ErrorMessage{
|
|
Error: fmt.Sprintf("failed to get unprocessed transactions: %v", err),
|
|
},
|
|
)
|
|
return
|
|
}
|
|
if len(lastTransactions) > 0 {
|
|
// 修改所有的待处理事务为取消状态
|
|
var unprocessedIDs []uuid.UUID
|
|
for j := range lastTransactions {
|
|
unprocessedIDs = append(unprocessedIDs, lastTransactions[j].ID)
|
|
}
|
|
db := dao.DBClient.GetGlobalDB()
|
|
if err := db.Model(&types.WorkspaceSubscriptionTransaction{}).
|
|
Where("id IN ?", unprocessedIDs).
|
|
Updates(map[string]any{
|
|
"status": types.SubscriptionTransactionStatusCanceled,
|
|
"status_desc": "Canceled due to account deletion request",
|
|
}).Error; err != nil {
|
|
// fmt.Errorf("failed to cancel unprocessed transactions for user %s/%s: %w", req.UserID, req.UserUID, err)
|
|
c.JSON(
|
|
http.StatusInternalServerError,
|
|
helper.ErrorMessage{
|
|
Error: fmt.Sprintf("failed to cancel unprocessed transactions: %v", err),
|
|
},
|
|
)
|
|
return
|
|
}
|
|
}
|
|
|
|
err = dao.DBClient.GlobalTransactionHandler(func(tx *gorm.DB) error {
|
|
// var createdTransactions []types.WorkspaceSubscriptionTransaction
|
|
var createdTransactionsLen int
|
|
|
|
for i := range subList {
|
|
if subList[i].Status == types.SubscriptionStatusDeleted {
|
|
continue
|
|
}
|
|
|
|
// if lastTransaction != nil &&
|
|
// lastTransaction.Operator == types.SubscriptionTransactionTypeDeleted &&
|
|
// (lastTransaction.Status == types.SubscriptionTransactionStatusPending ||
|
|
// lastTransaction.Status == types.SubscriptionTransactionStatusProcessing) {
|
|
// continue
|
|
//}
|
|
|
|
now := time.Now().UTC()
|
|
deleteTransaction := types.WorkspaceSubscriptionTransaction{
|
|
ID: uuid.New(),
|
|
From: types.TransactionFromUser,
|
|
Workspace: subList[i].Workspace,
|
|
RegionDomain: subList[i].RegionDomain,
|
|
UserUID: req.UserUID,
|
|
OldPlanName: subList[i].PlanName,
|
|
OldPlanStatus: subList[i].Status,
|
|
NewPlanName: subList[i].PlanName, // 删除时新旧计划名称相同
|
|
Operator: types.SubscriptionTransactionTypeDeleted,
|
|
StartAt: now,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
Status: types.SubscriptionTransactionStatusPending,
|
|
PayStatus: types.SubscriptionPayStatusNoNeed, // 删除操作无需付费
|
|
StatusDesc: "Account cancellation requested by user",
|
|
Amount: 0, // 删除操作无费用
|
|
}
|
|
|
|
if err := dao.DBClient.CreateWorkspaceSubscriptionTransaction(tx, &deleteTransaction); err != nil {
|
|
return fmt.Errorf("failed to create deletion transaction for workspace %s/%s: %w",
|
|
subList[i].Workspace, subList[i].RegionDomain, err)
|
|
}
|
|
|
|
createdTransactionsLen++
|
|
logrus.Infof(
|
|
"Created deletion transaction for workspace subscription: workspace=%s, region=%s, transaction_id=%s",
|
|
subList[i].Workspace,
|
|
subList[i].RegionDomain,
|
|
deleteTransaction.ID,
|
|
)
|
|
}
|
|
|
|
if createdTransactionsLen > 0 {
|
|
logrus.Infof(
|
|
"Account cancellation initiated for user %s: created %d deletion transactions",
|
|
req.UserUID,
|
|
createdTransactionsLen,
|
|
)
|
|
}
|
|
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
dao.Logger.Errorf("failed to cancel account for user %s: %v", req.UserUID, err)
|
|
c.JSON(
|
|
http.StatusInternalServerError,
|
|
helper.ErrorMessage{Error: fmt.Sprintf("failed to cancel account: %v", err)},
|
|
)
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"success": true,
|
|
"message": "Account cancellation has been initiated. All workspace subscriptions will be processed for deletion.",
|
|
})
|
|
}
|
|
|
|
// GetWorkspaceSubscriptionList
|
|
// @Summary Get workspace subscription list
|
|
// @Description Get workspace subscription list
|
|
// @Tags WorkspaceSubscription
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param req body WorkspaceSubscriptionListReq true "WorkspaceSubscriptionListReq"
|
|
// @Success 200 {object} WorkspaceSubscriptionListResp
|
|
// @Router /payment/v1alpha1/workspace-subscription/list [post]
|
|
func GetWorkspaceSubscriptionList(c *gin.Context) {
|
|
req := &helper.AuthBase{}
|
|
if err := authenticateRequest(c, req); err != nil {
|
|
c.JSON(
|
|
http.StatusUnauthorized,
|
|
helper.ErrorMessage{Error: fmt.Sprintf("authenticate error : %v", err)},
|
|
)
|
|
return
|
|
}
|
|
|
|
subscriptions, err := dao.DBClient.ListWorkspaceSubscription(req.UserUID)
|
|
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
c.JSON(
|
|
http.StatusInternalServerError,
|
|
helper.ErrorMessage{
|
|
Error: fmt.Sprintf("failed to get workspace subscription list: %v", err),
|
|
},
|
|
)
|
|
return
|
|
}
|
|
type WorkspaceSubscription struct {
|
|
*types.WorkspaceSubscription
|
|
Amount int64 `json:"amount"`
|
|
}
|
|
workspaceSubscriptions := make([]WorkspaceSubscription, len(subscriptions))
|
|
for i := range subscriptions {
|
|
workspaceSubscriptions[i] = WorkspaceSubscription{
|
|
WorkspaceSubscription: &subscriptions[i],
|
|
Amount: 0,
|
|
}
|
|
if subscriptions[i].PlanName == types.FreeSubscriptionPlanName {
|
|
continue
|
|
}
|
|
price, err := dao.DBClient.GetWorkspaceSubscriptionPlanPrice(
|
|
subscriptions[i].PlanName,
|
|
types.SubscriptionPeriodMonthly,
|
|
)
|
|
if err != nil {
|
|
logrus.Warnf("failed to get workspace subscription plan price: %v", err)
|
|
continue
|
|
}
|
|
if price != nil {
|
|
workspaceSubscriptions[i].Amount = price.Price
|
|
}
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"subscriptions": subscriptions,
|
|
})
|
|
}
|
|
|
|
// GetWorkspaceSubscriptionPaymentList
|
|
// @Summary Get workspace subscription payment list
|
|
// @Description Get workspace subscription payment list
|
|
// @Tags WorkspaceSubscription
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param req body WorkspaceSubscriptionInfoReq true "WorkspaceSubscriptionInfoReq"
|
|
// @Success 200 {object} WorkspaceSubscriptionPaymentListResp
|
|
// @Router /payment/v1alpha1/workspace-subscription/payment-list [post]
|
|
func GetWorkspaceSubscriptionPaymentList(c *gin.Context) {
|
|
req, err := helper.ParseUserTimeRangeReq(c)
|
|
if err != nil {
|
|
c.JSON(
|
|
http.StatusBadRequest,
|
|
helper.ErrorMessage{Error: fmt.Sprintf("failed to parse request: %v", err)},
|
|
)
|
|
return
|
|
}
|
|
if err := authenticateRequest(c, req); err != nil {
|
|
c.JSON(
|
|
http.StatusUnauthorized,
|
|
helper.ErrorMessage{Error: fmt.Sprintf("authenticate error: %v", err)},
|
|
)
|
|
return
|
|
}
|
|
|
|
type WorkspaceSubscriptionPayment struct {
|
|
ID string `gorm:"column:id"`
|
|
Time time.Time `gorm:"column:time"`
|
|
Amount int64 `gorm:"column:amount"`
|
|
PlanName string `gorm:"column:plan_name"`
|
|
Workspace string `gorm:"column:workspace"`
|
|
Operator string `gorm:"column:operator"`
|
|
Type string `gorm:"column:type"`
|
|
}
|
|
|
|
var payments []WorkspaceSubscriptionPayment
|
|
|
|
// Query using GORM with LEFT JOIN to include all payments
|
|
query := dao.DBClient.GetGlobalDB().
|
|
Model(&types.Payment{}).
|
|
Select(`"Payment".created_at AS time,
|
|
"Payment".id AS id,
|
|
"Payment".amount AS amount,
|
|
"WorkspaceSubscriptionTransaction".new_plan_name AS plan_name,
|
|
"WorkspaceSubscriptionTransaction".workspace AS workspace,
|
|
"WorkspaceSubscriptionTransaction".operator AS operator,
|
|
COALESCE("Payment".type, 'ACCOUNT_RECHARGE') AS type`).
|
|
Joins(`LEFT JOIN "WorkspaceSubscriptionTransaction" ON "Payment".id = "WorkspaceSubscriptionTransaction".pay_id`).
|
|
Where(`"Payment".status = ? AND "Payment"."userUid" = ?`, types.PaymentStatusPAID, req.UserUID)
|
|
|
|
// Add time range filter if StartTime and EndTime are valid
|
|
if !req.StartTime.IsZero() && !req.EndTime.IsZero() {
|
|
query = query.Where(`"Payment".created_at BETWEEN ? AND ?`, req.StartTime, req.EndTime)
|
|
}
|
|
|
|
// Add sorting to ensure consistent order
|
|
query = query.Order(`"Payment".created_at DESC`)
|
|
|
|
err = query.Scan(&payments).Error
|
|
if err != nil {
|
|
c.JSON(
|
|
http.StatusInternalServerError,
|
|
helper.ErrorMessage{Error: fmt.Sprintf("failed to query payments: %v", err)},
|
|
)
|
|
return
|
|
}
|
|
// Ensure payments is never nil
|
|
if payments == nil {
|
|
payments = make([]WorkspaceSubscriptionPayment, 0)
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"payments": payments,
|
|
})
|
|
}
|
|
|
|
// GetWorkspaceSubscriptionPlanList
|
|
// @Summary Get workspace subscription plan list
|
|
// @Description Get workspace subscription plan list
|
|
// @Tags WorkspaceSubscription
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Success 200 {object} WorkspaceSubscriptionPlanListResp
|
|
// @Router /payment/v1alpha1/workspace-subscription/plan-list [post]
|
|
func GetWorkspaceSubscriptionPlanList(c *gin.Context) {
|
|
plans, err := dao.DBClient.GetWorkspaceSubscriptionPlanList()
|
|
if err != nil {
|
|
c.JSON(
|
|
http.StatusInternalServerError,
|
|
helper.ErrorMessage{
|
|
Error: fmt.Sprintf("failed to get workspace subscription plan list: %v", err),
|
|
},
|
|
)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"plans": plans,
|
|
})
|
|
}
|
|
|
|
// GetLastWorkspaceSubscriptionTransaction
|
|
// @Summary Get last workspace subscription transaction
|
|
// @Description Get last workspace subscription transaction
|
|
// @Tags WorkspaceSubscription
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param req body WorkspaceSubscriptionInfoReq true "WorkspaceSubscriptionInfoReq"
|
|
// @Success 200 {object} WorkspaceSubscriptionLastTransactionResp
|
|
// @Router /payment/v1alpha1/workspace-subscription/last-transaction [post]
|
|
func GetLastWorkspaceSubscriptionTransaction(c *gin.Context) {
|
|
req, err := helper.ParseWorkspaceSubscriptionInfoReq(c)
|
|
if err != nil {
|
|
c.JSON(
|
|
http.StatusBadRequest,
|
|
helper.ErrorMessage{Error: fmt.Sprintf("failed to parse request: %v", err)},
|
|
)
|
|
return
|
|
}
|
|
if err := authenticateWorkspaceSubscriptionRequest(c, req, false); err != nil {
|
|
c.JSON(
|
|
http.StatusUnauthorized,
|
|
helper.ErrorMessage{Error: fmt.Sprintf("authenticate error : %v", err)},
|
|
)
|
|
return
|
|
}
|
|
|
|
transaction, err := dao.DBClient.GetLastWorkspaceSubscriptionTransaction(
|
|
req.Workspace,
|
|
req.RegionDomain,
|
|
)
|
|
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
c.JSON(
|
|
http.StatusInternalServerError,
|
|
helper.ErrorMessage{
|
|
Error: fmt.Sprintf(
|
|
"failed to get last workspace subscription transaction: %v",
|
|
err,
|
|
),
|
|
},
|
|
)
|
|
return
|
|
}
|
|
if transaction == nil {
|
|
transaction = &types.WorkspaceSubscriptionTransaction{}
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"transaction": transaction,
|
|
})
|
|
}
|
|
|
|
// GetWorkspaceSubscriptionUpgradeAmount
|
|
// @Summary Get workspace subscription upgrade amount
|
|
// @Description Get workspace subscription upgrade amount
|
|
// @Tags WorkspaceSubscription
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param req body WorkspaceSubscriptionUpgradeAmountReq true "WorkspaceSubscriptionUpgradeAmountReq"
|
|
// @Success 200 {object} WorkspaceSubscriptionUpgradeAmountResp
|
|
// @Router /payment/v1alpha1/workspace-subscription/upgrade-amount [post]
|
|
func GetWorkspaceSubscriptionUpgradeAmount(c *gin.Context) {
|
|
req, err := helper.ParseWorkspaceSubscriptionOperatorReq(c)
|
|
if err != nil {
|
|
c.JSON(
|
|
http.StatusBadRequest,
|
|
helper.ErrorMessage{Error: fmt.Sprintf("failed to parse request: %v", err)},
|
|
)
|
|
return
|
|
}
|
|
if err := authenticateWorkspaceSubscriptionOperatorRequest(c, req); err != nil {
|
|
c.JSON(
|
|
http.StatusUnauthorized,
|
|
helper.ErrorMessage{Error: fmt.Sprintf("authenticate error : %v", err)},
|
|
)
|
|
return
|
|
}
|
|
|
|
currentSubscription, err := dao.DBClient.GetWorkspaceSubscription(
|
|
req.Workspace,
|
|
req.RegionDomain,
|
|
)
|
|
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
c.JSON(
|
|
http.StatusInternalServerError,
|
|
helper.ErrorMessage{
|
|
Error: fmt.Sprintf("failed to get current workspace subscription: %v", err),
|
|
},
|
|
)
|
|
return
|
|
}
|
|
|
|
if currentSubscription == nil || currentSubscription.PlanName == req.PlanName {
|
|
c.JSON(
|
|
http.StatusBadRequest,
|
|
helper.ErrorMessage{
|
|
Error: "plan name is same as current plan or no current subscription",
|
|
},
|
|
)
|
|
return
|
|
}
|
|
|
|
currentPlan, err := dao.DBClient.GetWorkspaceSubscriptionPlan(currentSubscription.PlanName)
|
|
if err != nil {
|
|
c.JSON(
|
|
http.StatusInternalServerError,
|
|
helper.ErrorMessage{Error: fmt.Sprintf("failed to get current plan: %v", err)},
|
|
)
|
|
return
|
|
}
|
|
|
|
targetPlan, err := dao.DBClient.GetWorkspaceSubscriptionPlan(req.PlanName)
|
|
if err != nil {
|
|
c.JSON(
|
|
http.StatusInternalServerError,
|
|
helper.ErrorMessage{Error: fmt.Sprintf("failed to get target plan: %v", err)},
|
|
)
|
|
return
|
|
}
|
|
|
|
if currentSubscription.PayMethod == types.PaymentMethodStripe &&
|
|
currentSubscription.PayStatus == types.SubscriptionPayStatusPaid &&
|
|
currentSubscription.Stripe != nil {
|
|
price := getCurrentWorkspacePlanPrice(targetPlan, req.Period)
|
|
if price == nil || price.StripePrice == nil {
|
|
c.JSON(
|
|
http.StatusInternalServerError,
|
|
helper.ErrorMessage{
|
|
Error: fmt.Sprintf(
|
|
"no price found for target plan %s and period %s",
|
|
targetPlan.Name,
|
|
req.Period,
|
|
),
|
|
},
|
|
)
|
|
return
|
|
}
|
|
amount, err := services.StripeServiceInstance.UpdatePlanPricePreview(
|
|
currentSubscription.Stripe.SubscriptionID,
|
|
*price.StripePrice,
|
|
)
|
|
if err != nil {
|
|
c.JSON(
|
|
http.StatusInternalServerError,
|
|
helper.ErrorMessage{
|
|
Error: fmt.Sprintf("failed to get upgrade amount from stripe: %v", err),
|
|
},
|
|
)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"amount": amount * 10_000, // Convert cents to dollars
|
|
})
|
|
return
|
|
}
|
|
|
|
// Calculate upgrade amount based on period
|
|
var currentPrice, targetPrice int64
|
|
for _, price := range currentPlan.Prices {
|
|
if price.BillingCycle == req.Period {
|
|
currentPrice = price.Price
|
|
break
|
|
}
|
|
}
|
|
for _, price := range targetPlan.Prices {
|
|
if price.BillingCycle == req.Period {
|
|
targetPrice = price.Price
|
|
break
|
|
}
|
|
}
|
|
|
|
if targetPrice <= currentPrice {
|
|
c.JSON(
|
|
http.StatusBadRequest,
|
|
helper.ErrorMessage{Error: "target plan price is not higher than current plan"},
|
|
)
|
|
return
|
|
}
|
|
|
|
// Calculate prorated amount based on usage
|
|
alreadyUsedDays := time.Since(currentSubscription.CurrentPeriodStartAt).Hours() / 24
|
|
periodDays := float64(30) // Default to monthly
|
|
if req.Period == types.SubscriptionPeriodYearly {
|
|
periodDays = 365
|
|
}
|
|
|
|
usedAmount := (periodDays - alreadyUsedDays) / periodDays * float64(currentPrice)
|
|
amount := float64(targetPrice) - usedAmount
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"amount": int64(amount),
|
|
})
|
|
}
|
|
|
|
// CreateWorkspaceSubscriptionPay
|
|
// @Summary Create workspace subscription payment
|
|
// @Description Create workspace subscription payment with Stripe
|
|
// @Tags WorkspaceSubscription
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param req body WorkspaceSubscriptionOperatorReq true "WorkspaceSubscriptionOperatorReq"
|
|
// @Success 200 {object} WorkspaceSubscriptionPayResp
|
|
// @Router /payment/v1alpha1/workspace-subscription/pay [post]
|
|
func CreateWorkspaceSubscriptionPay(c *gin.Context) {
|
|
req, err := helper.ParseWorkspaceSubscriptionOperatorReq(c)
|
|
if err != nil {
|
|
SetErrorResp(
|
|
c,
|
|
http.StatusBadRequest,
|
|
gin.H{"error": fmt.Sprintf("failed to parse request: %v", err)},
|
|
)
|
|
return
|
|
}
|
|
if err := authenticateWorkspaceSubscriptionOperatorRequest(c, req); err != nil {
|
|
SetErrorResp(
|
|
c,
|
|
http.StatusUnauthorized,
|
|
gin.H{
|
|
"error": fmt.Sprintf("authenticate error : %v", err),
|
|
"code": http.StatusUnauthorized,
|
|
},
|
|
)
|
|
return
|
|
}
|
|
|
|
// Get current subscription (if exists)
|
|
currentSubscription, err := dao.DBClient.GetWorkspaceSubscription(
|
|
req.Workspace,
|
|
req.RegionDomain,
|
|
)
|
|
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
SetErrorResp(
|
|
c,
|
|
http.StatusInternalServerError,
|
|
gin.H{"error": fmt.Sprintf("failed to get workspace subscription: %v", err)},
|
|
)
|
|
return
|
|
}
|
|
|
|
// Validate plan changes
|
|
if currentSubscription != nil && currentSubscription.PlanName == req.PlanName &&
|
|
req.Operator != types.SubscriptionTransactionTypeRenewed {
|
|
SetErrorResp(c, http.StatusBadRequest, gin.H{"error": "plan name is same as current plan"})
|
|
return
|
|
}
|
|
|
|
// Get target plan
|
|
targetPlan, err := dao.DBClient.GetWorkspaceSubscriptionPlan(req.PlanName)
|
|
if err != nil {
|
|
SetErrorResp(
|
|
c,
|
|
http.StatusInternalServerError,
|
|
gin.H{"error": fmt.Sprintf("failed to get subscription plan: %v", err)},
|
|
)
|
|
return
|
|
}
|
|
|
|
// Get all workspace subscription plans for validation
|
|
planList, err := dao.DBClient.GetWorkspaceSubscriptionPlanList()
|
|
if err != nil {
|
|
SetErrorResp(
|
|
c,
|
|
http.StatusInternalServerError,
|
|
gin.H{"error": fmt.Sprintf("failed to get workspace subscription plan list: %v", err)},
|
|
)
|
|
return
|
|
}
|
|
|
|
var currentPlan *types.WorkspaceSubscriptionPlan
|
|
if currentSubscription != nil {
|
|
for _, plan := range planList {
|
|
if plan.Name == currentSubscription.PlanName {
|
|
currentPlan = &plan
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// Find price for the specified period
|
|
var planPrice *types.ProductPrice
|
|
for _, price := range targetPlan.Prices {
|
|
if string(price.BillingCycle) == string(req.Period) {
|
|
planPrice = &price
|
|
break
|
|
}
|
|
}
|
|
if planPrice == nil {
|
|
SetErrorResp(
|
|
c,
|
|
http.StatusBadRequest,
|
|
gin.H{"error": "no price found for specified period"},
|
|
)
|
|
return
|
|
}
|
|
currentPlanPrice := getCurrentWorkspacePlanPrice(currentPlan, req.Period)
|
|
// if currentSubscription.PlanName == types.FreeSubscriptionPlanName && req.Operator == types.SubscriptionTransactionTypeUpgraded {
|
|
// req.Operator = types.SubscriptionTransactionTypeCreated
|
|
//}
|
|
|
|
// Create subscription transaction with direct operator usage
|
|
transaction := types.WorkspaceSubscriptionTransaction{
|
|
ID: uuid.New(),
|
|
From: types.TransactionFromUser,
|
|
Workspace: req.Workspace,
|
|
RegionDomain: req.RegionDomain,
|
|
UserUID: req.UserUID,
|
|
NewPlanName: req.PlanName,
|
|
Operator: req.Operator, // Use operator directly from request
|
|
StartAt: time.Now().UTC(),
|
|
CreatedAt: time.Now().UTC(),
|
|
Status: types.SubscriptionTransactionStatusProcessing,
|
|
Period: req.Period,
|
|
Amount: planPrice.Price,
|
|
}
|
|
|
|
if currentSubscription != nil {
|
|
transaction.OldPlanName = currentSubscription.PlanName
|
|
transaction.OldPlanStatus = currentSubscription.Status
|
|
}
|
|
|
|
// Validate plan transitions and calculate pricing based on operator
|
|
switch req.Operator {
|
|
case types.SubscriptionTransactionTypeCreated:
|
|
// No additional validation needed for creation
|
|
if currentSubscription != nil &&
|
|
currentSubscription.PlanName != types.FreeSubscriptionPlanName {
|
|
SetErrorResp(
|
|
c,
|
|
http.StatusBadRequest,
|
|
gin.H{"error": "cannot create new subscription with existing subscription"},
|
|
)
|
|
return
|
|
}
|
|
transaction.Amount = planPrice.Price // Full price for new subscription
|
|
case types.SubscriptionTransactionTypeUpgraded:
|
|
if currentSubscription == nil {
|
|
SetErrorResp(
|
|
c,
|
|
http.StatusBadRequest,
|
|
gin.H{"error": "cannot upgrade without existing subscription"},
|
|
)
|
|
return
|
|
}
|
|
if currentPlan == nil {
|
|
SetErrorResp(
|
|
c,
|
|
http.StatusBadRequest,
|
|
gin.H{"error": "cannot upgrade without existing plan"},
|
|
)
|
|
return
|
|
}
|
|
if !contain(currentPlan.UpgradePlanList, req.PlanName) {
|
|
SetErrorResp(
|
|
c,
|
|
http.StatusBadRequest,
|
|
gin.H{
|
|
"error": fmt.Sprintf(
|
|
"plan name is not in upgrade plan list: %v",
|
|
currentPlan.UpgradePlanList,
|
|
),
|
|
},
|
|
)
|
|
return
|
|
}
|
|
if currentPlanPrice != nil && currentPlanPrice.Price > 0 {
|
|
// Calculate prorated amount based on usage
|
|
alreadyUsedDays := time.Since(currentSubscription.CurrentPeriodStartAt).Hours() / 24
|
|
period, err := types.ParsePeriod(req.Period)
|
|
if err != nil {
|
|
SetErrorResp(
|
|
c,
|
|
http.StatusBadRequest,
|
|
gin.H{"error": fmt.Sprintf("invalid period: %v", err)},
|
|
)
|
|
return
|
|
}
|
|
periodDays := getPeriodDays(period)
|
|
usedAmount := (periodDays - alreadyUsedDays) / periodDays * float64(
|
|
currentPlanPrice.Price,
|
|
)
|
|
value := float64(planPrice.Price) - usedAmount
|
|
if value > 0 {
|
|
transaction.Amount = int64(value)
|
|
} else {
|
|
transaction.Amount = 0
|
|
}
|
|
}
|
|
|
|
case types.SubscriptionTransactionTypeDowngraded:
|
|
if currentSubscription == nil {
|
|
SetErrorResp(
|
|
c,
|
|
http.StatusBadRequest,
|
|
gin.H{"error": "cannot downgrade without existing subscription"},
|
|
)
|
|
return
|
|
}
|
|
if currentPlan != nil && !contain(currentPlan.DowngradePlanList, req.PlanName) {
|
|
SetErrorResp(
|
|
c,
|
|
http.StatusBadRequest,
|
|
gin.H{
|
|
"error": fmt.Sprintf(
|
|
"plan name is not in downgrade plan list: %v",
|
|
currentPlan.DowngradePlanList,
|
|
),
|
|
},
|
|
)
|
|
return
|
|
}
|
|
transaction.StartAt = currentSubscription.CurrentPeriodEndAt.Add(-20 * time.Minute)
|
|
// Downgrade takes effect at next cycle and typically has no cost
|
|
if transaction.StartAt.Before(time.Now()) {
|
|
transaction.StartAt = time.Now()
|
|
}
|
|
transaction.Status = types.SubscriptionTransactionStatusPending
|
|
transaction.PayStatus = types.SubscriptionPayStatusUnpaid
|
|
transaction.Amount = 0 // Downgrades are typically free
|
|
|
|
case types.SubscriptionTransactionTypeRenewed:
|
|
if currentSubscription == nil {
|
|
SetErrorResp(
|
|
c,
|
|
http.StatusBadRequest,
|
|
gin.H{"error": "cannot renew without existing subscription"},
|
|
)
|
|
return
|
|
}
|
|
if currentSubscription.PlanName != req.PlanName {
|
|
SetErrorResp(
|
|
c,
|
|
http.StatusBadRequest,
|
|
gin.H{"error": "plan name is not same as current plan for renewal"},
|
|
)
|
|
return
|
|
}
|
|
// Renewal uses full plan price
|
|
|
|
case types.SubscriptionTransactionTypeCanceled:
|
|
if currentSubscription == nil {
|
|
SetErrorResp(
|
|
c,
|
|
http.StatusBadRequest,
|
|
gin.H{"error": "cannot cancel without existing subscription"},
|
|
)
|
|
return
|
|
}
|
|
// Cancellation has no cost
|
|
transaction.Amount = 0
|
|
transaction.Status = types.SubscriptionTransactionStatusPending
|
|
}
|
|
|
|
// Handle concurrent safety and last transaction validation before payment processing
|
|
err = handleWorkspaceSubscriptionTransactionWithConcurrencyControl(
|
|
c,
|
|
currentSubscription,
|
|
req,
|
|
transaction,
|
|
)
|
|
if err != nil && !errors.Is(err, ErrSamePendingOperation) {
|
|
// logrus.Errorf("handle workspace subscription transaction error: %v", err)
|
|
dao.Logger.Errorf("handle workspace subscription transaction error: %v", err)
|
|
// Error response already handled in the function
|
|
return
|
|
}
|
|
}
|
|
|
|
// Helper functions for workspace subscription payment logic
|
|
|
|
var ErrSamePendingOperation = errors.New("same pending operation exists")
|
|
|
|
// handleWorkspaceSubscriptionTransactionWithConcurrencyControl provides unified transaction handling with concurrency control
|
|
func handleWorkspaceSubscriptionTransactionWithConcurrencyControl(
|
|
c *gin.Context,
|
|
subscription *types.WorkspaceSubscription,
|
|
req *helper.WorkspaceSubscriptionOperatorReq,
|
|
transaction types.WorkspaceSubscriptionTransaction,
|
|
) error {
|
|
// Use database transaction to ensure concurrency control for the same workspace/region
|
|
return dao.DBClient.GlobalTransactionHandler(func(tx *gorm.DB) error {
|
|
// Check for existing pending transactions for the same workspace/region
|
|
lastTransaction, err := dao.DBClient.GetLastWorkspaceSubscriptionTransaction(
|
|
req.Workspace,
|
|
req.RegionDomain,
|
|
)
|
|
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
SetErrorResp(
|
|
c,
|
|
http.StatusInternalServerError,
|
|
gin.H{"error": fmt.Sprintf("failed to get last transaction: %v", err)},
|
|
)
|
|
return err
|
|
}
|
|
|
|
logrus.Infof("last workspace subscription transaction: %v", lastTransaction)
|
|
|
|
// Handle existing pending/processing transactions
|
|
if lastTransaction != nil &&
|
|
(lastTransaction.Status == types.SubscriptionTransactionStatusProcessing || lastTransaction.Status == types.SubscriptionTransactionStatusPending) {
|
|
// 判断是否为相同请求
|
|
isSameRequest := lastTransaction.NewPlanName == req.PlanName &&
|
|
lastTransaction.Operator == req.Operator &&
|
|
lastTransaction.Period == req.Period
|
|
|
|
switch {
|
|
case isSameRequest:
|
|
if lastTransaction.PayStatus == types.SubscriptionPayStatusPending &&
|
|
lastTransaction.PayID != "" {
|
|
if strings.ToLower(string(req.PayMethod)) == helper.STRIPE {
|
|
// 对于 Stripe 支付,检查 PaymentOrder 状态
|
|
var paymentOrder types.PaymentOrder
|
|
if err := tx.Where("id = ?", lastTransaction.PayID).First(&paymentOrder).Error; err == nil {
|
|
if paymentOrder.Status == types.PaymentOrderStatusPending &&
|
|
paymentOrder.CodeURL != "" {
|
|
// Session 仍然有效,返回上次的支付链接
|
|
logrus.Infof(
|
|
"Returning existing Stripe session for same request: %s",
|
|
paymentOrder.CodeURL,
|
|
)
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"redirectUrl": paymentOrder.CodeURL,
|
|
"payID": paymentOrder.ID,
|
|
"success": true,
|
|
})
|
|
return ErrSamePendingOperation
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// 如果上次session已经失效,继续处理下面的逻辑
|
|
case lastTransaction.PayStatus == types.SubscriptionPayStatusPending:
|
|
// 不同请求,需要关闭/取消/等待 上次的请求完成后在处理下次
|
|
logrus.Infof(
|
|
"Different request detected, canceling previous transaction. Old: plan=%s, operator=%s, period=%s; New: plan=%s, operator=%s, period=%s",
|
|
lastTransaction.NewPlanName,
|
|
lastTransaction.Operator,
|
|
lastTransaction.Period,
|
|
req.PlanName,
|
|
req.Operator,
|
|
req.Period,
|
|
)
|
|
var paymentOrder types.PaymentOrder
|
|
if lastTransaction.PayID != "" {
|
|
if err := tx.Where("id = ?", lastTransaction.PayID).First(&paymentOrder).Error; err != nil &&
|
|
!errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return fmt.Errorf("failed to get last payment order: %w", err)
|
|
}
|
|
}
|
|
// TODO 需要调用 stripe.Api去取消交易,防止重复调用
|
|
if paymentOrder.Stripe != nil && paymentOrder.Stripe.SessionID != "" {
|
|
// 标记上次交易为已取消
|
|
if err := tx.Model(&lastTransaction).Updates(map[string]any{
|
|
"status": types.SubscriptionTransactionStatusFailed,
|
|
"pay_status": types.SubscriptionPayStatusCanceled,
|
|
"status_desc": "Canceled due to new different request",
|
|
}).Error; err != nil {
|
|
return fmt.Errorf("failed to cancel previous transaction: %w", err)
|
|
}
|
|
|
|
if err := tx.Model(&types.PaymentOrder{}).Where("id = ?", lastTransaction.PayID).Update("status", types.PaymentStatusExpired).Error; err != nil {
|
|
logrus.Errorf("Failed to cancel previous payment order: %v", err)
|
|
}
|
|
if err := services.StripeServiceInstance.CancelWorkspaceSubscriptionSession(paymentOrder.Stripe.SessionID); err != nil {
|
|
return fmt.Errorf(
|
|
"failed to cancel workspace subscription session: %w",
|
|
err,
|
|
)
|
|
}
|
|
} else {
|
|
c.JSON(http.StatusConflict, gin.H{"error": "a different subscription operation is still pending, please wait for it to complete"})
|
|
return errors.New("different pending operation exists")
|
|
}
|
|
default:
|
|
// 原有的特殊情况处理逻辑
|
|
switch {
|
|
case lastTransaction.Operator == types.SubscriptionTransactionTypeDowngraded:
|
|
// Delete old downgrade transaction and continue
|
|
if err := tx.Delete(&lastTransaction).Error; err != nil {
|
|
SetErrorResp(
|
|
c,
|
|
http.StatusInternalServerError,
|
|
gin.H{
|
|
"error": fmt.Sprintf(
|
|
"failed to delete last subscription transaction: %v",
|
|
err,
|
|
),
|
|
},
|
|
)
|
|
return err
|
|
}
|
|
logrus.Infof("Deleted old downgrade transaction, continuing with new operation")
|
|
case lastTransaction.PayStatus == types.SubscriptionPayStatusNoNeed:
|
|
SetErrorResp(
|
|
c,
|
|
http.StatusConflict,
|
|
gin.H{
|
|
"error": "The last subscription operation was not processed, please wait for the next cycle",
|
|
},
|
|
)
|
|
return errors.New("pending operation exists")
|
|
case lastTransaction.PayStatus == types.SubscriptionPayStatusFailed:
|
|
// Mark old transaction as failed and continue
|
|
tx.Model(&lastTransaction).
|
|
Update("status", types.SubscriptionTransactionStatusFailed)
|
|
logrus.Errorf(
|
|
"last workspace subscription transaction pay failed, workspace: %s/%s",
|
|
req.Workspace,
|
|
req.RegionDomain,
|
|
)
|
|
default:
|
|
switch lastTransaction.PayStatus {
|
|
case types.SubscriptionPayStatusPending,
|
|
types.SubscriptionPayStatusProcessing,
|
|
types.SubscriptionPayStatusPaid,
|
|
types.SubscriptionPayStatusUnpaid,
|
|
types.SubscriptionPayStatusCanceled:
|
|
SetErrorResp(
|
|
c,
|
|
http.StatusConflict,
|
|
gin.H{
|
|
"error": "there is already a pending workspace subscription transaction",
|
|
},
|
|
)
|
|
default:
|
|
SetErrorResp(
|
|
c,
|
|
http.StatusConflict,
|
|
gin.H{
|
|
"error": "there is already a pending workspace subscription transaction",
|
|
},
|
|
)
|
|
}
|
|
return errors.New("pending transaction exists")
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil // Validation passed, continue with payment processing
|
|
}, func(tx *gorm.DB) error {
|
|
// Process payment based on amount and method
|
|
if transaction.Amount > 0 || req.Operator == types.SubscriptionTransactionTypeUpgraded {
|
|
ok, err := CheckQuota(context.Background(), req.Workspace, transaction.NewPlanName)
|
|
if err != nil {
|
|
SetErrorResp(
|
|
c,
|
|
http.StatusInternalServerError,
|
|
gin.H{"error": fmt.Sprintf("failed to check quota: %v", err)},
|
|
)
|
|
return err
|
|
}
|
|
if !ok {
|
|
SetErrorResp(
|
|
c,
|
|
http.StatusInternalServerError,
|
|
gin.H{
|
|
"error": "quota exceeded for the requested plan, please change the resource usage to within the expected workspace subscription quota",
|
|
"code": 10004,
|
|
},
|
|
)
|
|
return fmt.Errorf(
|
|
"workspace %s quota exceeded for the requested plan: %s",
|
|
transaction.Workspace,
|
|
transaction.NewPlanName,
|
|
)
|
|
}
|
|
// Payment required - route to appropriate payment method
|
|
switch req.PayMethod {
|
|
case helper.STRIPE:
|
|
return processStripePaymentInTransaction(tx, c, req, subscription, transaction)
|
|
case helper.BALANCE:
|
|
return processBalancePaymentInTransaction(tx, c, req, transaction)
|
|
default:
|
|
SetErrorResp(c, http.StatusBadRequest, gin.H{"error": "unsupported payment method"})
|
|
return errors.New("unsupported payment method")
|
|
}
|
|
} else {
|
|
// No payment required (downgrades, cancellations) - set payStatus to no_need
|
|
transaction.PayStatus = types.SubscriptionPayStatusNoNeed
|
|
return processNoPaymentOperationInTransaction(tx, c, subscription, req, transaction)
|
|
}
|
|
})
|
|
}
|
|
|
|
func getCurrentWorkspacePlanPrice(
|
|
plan *types.WorkspaceSubscriptionPlan,
|
|
period types.SubscriptionPeriod,
|
|
) *types.ProductPrice {
|
|
if plan == nil {
|
|
return nil
|
|
}
|
|
for _, price := range plan.Prices {
|
|
if string(price.BillingCycle) == string(period) {
|
|
return &price
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func getPeriodDays(period time.Duration) float64 {
|
|
return float64(period.Hours()) / 24
|
|
}
|
|
|
|
// 创建订阅需要判断当前订阅状态,如果是active,不能创建新的订阅,只能升级或续费,如果是canceled或past_due,可以创建新的订阅
|
|
// processStripePaymentInTransaction handles Stripe payment within existing transaction
|
|
func processStripePaymentInTransaction(
|
|
tx *gorm.DB,
|
|
c *gin.Context,
|
|
req *helper.WorkspaceSubscriptionOperatorReq,
|
|
subscription *types.WorkspaceSubscription,
|
|
transaction types.WorkspaceSubscriptionTransaction,
|
|
) error {
|
|
// 1. Initialize transaction data
|
|
if err := initializeTransactionData(tx, &transaction, req); err != nil {
|
|
SetErrorResp(
|
|
c,
|
|
http.StatusInternalServerError,
|
|
gin.H{"error": fmt.Sprintf("failed to initialize transaction: %v", err)},
|
|
)
|
|
return err
|
|
}
|
|
|
|
// 2. Get plan price information
|
|
price, err := dao.DBClient.GetWorkspaceSubscriptionPlanPrice(
|
|
transaction.NewPlanName,
|
|
transaction.Period,
|
|
)
|
|
if err != nil {
|
|
SetErrorResp(
|
|
c,
|
|
http.StatusInternalServerError,
|
|
gin.H{"error": fmt.Sprintf("failed to get plan price: %v", err)},
|
|
)
|
|
return err
|
|
}
|
|
|
|
// 3. Process based on operation type
|
|
switch transaction.Operator {
|
|
case types.SubscriptionTransactionTypeCreated, types.SubscriptionTransactionTypeRenewed:
|
|
return processNewSubscription(tx, c, req, price, transaction)
|
|
case types.SubscriptionTransactionTypeUpgraded:
|
|
return processUpgradeSubscription(tx, c, req, subscription, price, transaction)
|
|
default:
|
|
SetErrorResp(
|
|
c,
|
|
http.StatusBadRequest,
|
|
gin.H{"error": "unsupported operator for Stripe payment"},
|
|
)
|
|
return errors.New("unsupported operator")
|
|
}
|
|
}
|
|
|
|
// initializeTransactionData initializes common transaction data
|
|
func initializeTransactionData(
|
|
_ *gorm.DB,
|
|
transaction *types.WorkspaceSubscriptionTransaction,
|
|
_ *helper.WorkspaceSubscriptionOperatorReq,
|
|
) error {
|
|
// Generate payment ID if not provided
|
|
if transaction.PayID == "" {
|
|
paymentID, err := gonanoid.New(12)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create payment id: %w", err)
|
|
}
|
|
transaction.PayID = paymentID
|
|
}
|
|
|
|
transaction.PayStatus = types.SubscriptionPayStatusPending
|
|
return nil
|
|
}
|
|
|
|
// processNewSubscription handles new subscription creation and renewal
|
|
func processNewSubscription(
|
|
tx *gorm.DB,
|
|
c *gin.Context,
|
|
req *helper.WorkspaceSubscriptionOperatorReq,
|
|
price *types.ProductPrice,
|
|
transaction types.WorkspaceSubscriptionTransaction,
|
|
) error {
|
|
// Create transaction record
|
|
if transaction.Status == "" {
|
|
transaction.Status = types.SubscriptionTransactionStatusProcessing
|
|
}
|
|
if transaction.OldPlanStatus == "" {
|
|
transaction.OldPlanStatus = types.SubscriptionStatusNormal
|
|
}
|
|
if err := dao.DBClient.CreateWorkspaceSubscriptionTransaction(tx, &transaction); err != nil {
|
|
SetErrorResp(
|
|
c,
|
|
http.StatusInternalServerError,
|
|
gin.H{
|
|
"error": fmt.Sprintf(
|
|
"failed to create workspace subscription transaction: %v",
|
|
err,
|
|
),
|
|
},
|
|
)
|
|
return err
|
|
}
|
|
|
|
// Create Stripe session and payment order
|
|
return createStripeSessionAndPaymentOrder(tx, c, req, price, transaction)
|
|
}
|
|
|
|
// processUpgradeSubscription handles subscription upgrades
|
|
func processUpgradeSubscription(
|
|
tx *gorm.DB,
|
|
c *gin.Context,
|
|
req *helper.WorkspaceSubscriptionOperatorReq,
|
|
subscription *types.WorkspaceSubscription,
|
|
price *types.ProductPrice,
|
|
transaction types.WorkspaceSubscriptionTransaction,
|
|
) error {
|
|
// Handle upgrade from Free plan (new subscription creation)
|
|
if transaction.OldPlanName == types.FreeSubscriptionPlanName {
|
|
return processNewSubscription(tx, c, req, price, transaction)
|
|
}
|
|
|
|
// Handle upgrade from paid plan (subscription modification)
|
|
if price == nil || price.StripePrice == nil {
|
|
SetErrorResp(
|
|
c,
|
|
http.StatusInternalServerError,
|
|
gin.H{"error": "new plan has no valid Stripe price"},
|
|
)
|
|
return errors.New("new plan has no valid Stripe price")
|
|
}
|
|
|
|
// Create transaction record first
|
|
transaction.Status = types.SubscriptionTransactionStatusProcessing
|
|
transaction.PayStatus = types.SubscriptionPayStatusPending
|
|
if transaction.PayID == "" {
|
|
paymentID, err := gonanoid.New(12)
|
|
if err != nil {
|
|
SetErrorResp(
|
|
c,
|
|
http.StatusInternalServerError,
|
|
gin.H{"error": fmt.Sprintf("failed to create payment id: %v", err)},
|
|
)
|
|
return err
|
|
}
|
|
transaction.PayID = paymentID
|
|
}
|
|
if err := dao.DBClient.CreateWorkspaceSubscriptionTransaction(tx, &transaction); err != nil {
|
|
SetErrorResp(
|
|
c,
|
|
http.StatusInternalServerError,
|
|
gin.H{
|
|
"error": fmt.Sprintf(
|
|
"failed to create workspace subscription transaction: %v",
|
|
err,
|
|
),
|
|
},
|
|
)
|
|
return err
|
|
}
|
|
|
|
// No payment order for subscription update (handled via invoice webhook)
|
|
// Update Stripe subscription - this will trigger proration invoice
|
|
// Ensure metadata is set for webhook identification (assume UpdatePlan handles metadata update with pay_id, operator, etc.)
|
|
stripeResp, err := services.StripeServiceInstance.UpdatePlan(
|
|
subscription.Stripe.SubscriptionID,
|
|
*price.StripePrice,
|
|
transaction.NewPlanName,
|
|
transaction.PayID,
|
|
)
|
|
if err != nil {
|
|
// If update fails, mark transaction as failed
|
|
transaction.Status = types.SubscriptionTransactionStatusFailed
|
|
transaction.PayStatus = types.SubscriptionPayStatusFailed
|
|
transaction.StatusDesc = fmt.Sprintf("Failed to update Stripe subscription: %v", err)
|
|
tx.Save(&transaction)
|
|
SetErrorResp(
|
|
c,
|
|
http.StatusInternalServerError,
|
|
gin.H{"error": fmt.Sprintf("failed to update Stripe subscription: %v", err)},
|
|
)
|
|
return err
|
|
}
|
|
|
|
// Update transaction to pending awaiting webhook confirmation
|
|
transaction.Status = types.SubscriptionTransactionStatusPending
|
|
tx.Save(&transaction)
|
|
|
|
logrus.Infof(
|
|
"workspace: %s, Stripe subscription updated: %s, status: %s, awaiting webhook confirmation",
|
|
subscription.Workspace,
|
|
stripeResp.ID,
|
|
stripeResp.Status,
|
|
)
|
|
|
|
// Return response indicating upgrade initiated
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"success": true,
|
|
"message": "Subscription upgrade initiated, payment processing via webhook",
|
|
})
|
|
return nil
|
|
}
|
|
|
|
// processUpgradeSubscription handles subscription upgrades
|
|
// func processUpgradeSubscription(tx *gorm.DB, c *gin.Context, req *helper.WorkspaceSubscriptionOperatorReq, subscription *types.WorkspaceSubscription, price *types.ProductPrice, transaction types.WorkspaceSubscriptionTransaction) error {
|
|
// // Handle upgrade from Free plan (new subscription creation)
|
|
// if transaction.OldPlanName == types.FreeSubscriptionPlanName {
|
|
// return processNewSubscription(tx, c, req, price, transaction)
|
|
// }
|
|
//
|
|
// // Handle upgrade from paid plan (subscription modification)
|
|
// if price == nil || price.StripePrice == nil {
|
|
// SetErrorResp(c, http.StatusInternalServerError, gin.H{"error": "new plan has no valid Stripe price"})
|
|
// return fmt.Errorf("new plan has no valid Stripe price")
|
|
// }
|
|
//
|
|
// workspace, regionDomain := req.Workspace, req.RegionDomain
|
|
// err := dao.DBClient.GlobalTransactionHandler(func(tx *gorm.DB) error {
|
|
// // Update existing Stripe subscription
|
|
// stripeResp, err := services.StripeServiceInstance.UpdatePlan(subscription.Stripe.SubscriptionID, *price.StripePrice, transaction.NewPlanName, transaction.PayID)
|
|
// if err != nil {
|
|
// SetErrorResp(c, http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to update Stripe subscription: %v", err)})
|
|
// return err
|
|
// }
|
|
// targetPlan, err := dao.DBClient.GetWorkspaceSubscriptionPlan(req.PlanName)
|
|
// if err != nil {
|
|
// SetErrorResp(c, http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to get target plan: %v", err)})
|
|
// return err
|
|
// }
|
|
// pri := getCurrentWorkspacePlanPrice(targetPlan, req.Period)
|
|
// if pri == nil || pri.StripePrice == nil {
|
|
// c.JSON(http.StatusInternalServerError, helper.ErrorMessage{Error: fmt.Sprintf("no price found for target plan %s and period %s", targetPlan.Name, req.Period)})
|
|
// return fmt.Errorf("no price found for target plan %s and period %s", targetPlan.Name, req.Period)
|
|
// }
|
|
// amount, err := services.StripeServiceInstance.UpdatePlanPricePreview(subscription.Stripe.SubscriptionID, *price.StripePrice)
|
|
// if err != nil {
|
|
// c.JSON(http.StatusInternalServerError, helper.ErrorMessage{Error: fmt.Sprintf("failed to get upgrade amount from stripe: %v", err)})
|
|
// return fmt.Errorf("failed to get upgrade amount from stripe: %v", err)
|
|
// }
|
|
// //stripeResp.LatestInvoice.AmountPaid
|
|
// data, _ := json.MarshalIndent(stripeResp, "", " ")
|
|
// fmt.Printf("stripe Resp: %s\n", string(data))
|
|
// payment := &types.Payment{
|
|
// ID: transaction.PayID,
|
|
// PaymentRaw: types.PaymentRaw{
|
|
// Stripe: subscription.Stripe,
|
|
// UserUID: req.UserUID,
|
|
// RegionUID: dao.DBClient.GetLocalRegion().UID,
|
|
// CreatedAt: time.Now().UTC(),
|
|
// Method: helper.STRIPE,
|
|
// // amount invoice.AmountPaid / 10_000
|
|
// Amount: amount * 10_000,
|
|
// TradeNO: stripeResp.LatestInvoice.ID,
|
|
// Type: types.PaymentTypeSubscription,
|
|
// ChargeSource: types.ChargeSourceStripe,
|
|
// Status: types.PaymentStatusPAID,
|
|
// WorkspaceSubscriptionID: &subscription.ID,
|
|
// Message: fmt.Sprintf("Payment for workspace %s/%s (%s)", workspace, regionDomain, stripeResp.LatestInvoice.BillingReason),
|
|
// },
|
|
// }
|
|
// err = finalizeWorkspaceSubscriptionSuccess(tx, subscription, &transaction, payment, false)
|
|
// if err != nil {
|
|
// return fmt.Errorf("failed to finalize subscription payment: %v", err)
|
|
// }
|
|
// logrus.Infof("workspace: %s, Stripe subscription updated: %s, status: %s", subscription.Workspace, stripeResp.ID, stripeResp.Status)
|
|
// return nil
|
|
// })
|
|
// if err != nil {
|
|
// c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to process upgrade: %v", err)})
|
|
// return fmt.Errorf("failed to update Stripe subscription: %v", err)
|
|
// }
|
|
// c.JSON(http.StatusOK, gin.H{
|
|
// "success": true,
|
|
// "message": "Subscription upgraded successfully, waiting for update",
|
|
// })
|
|
// return nil
|
|
//}
|
|
|
|
// createStripeSessionAndPaymentOrder creates Stripe session and corresponding payment order
|
|
func createStripeSessionAndPaymentOrder(
|
|
tx *gorm.DB,
|
|
c *gin.Context,
|
|
req *helper.WorkspaceSubscriptionOperatorReq,
|
|
price *types.ProductPrice,
|
|
transaction types.WorkspaceSubscriptionTransaction,
|
|
) error {
|
|
// Create payment request
|
|
paymentReq := services.PaymentRequest{
|
|
RequestID: uuid.NewString(),
|
|
UserUID: req.UserUID,
|
|
Amount: transaction.Amount,
|
|
Currency: dao.PaymentCurrency,
|
|
UserAgent: c.GetHeader("User-Agent"),
|
|
ClientIP: c.ClientIP(),
|
|
DeviceTokenID: c.GetHeader("Device-Token-ID"),
|
|
}
|
|
// Get or create Stripe customer ID
|
|
// customer, err := services.StripeServiceInstance.GetCustomerByUID(req.UserUID.String())
|
|
// if err != nil {
|
|
// SetErrorResp(c, http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to get or create stripe customer: %v", err)})
|
|
// return err
|
|
//}
|
|
// paymentReq.CustomerID = &customer.ID
|
|
customerID, err := dao.DBClient.GetUserStripeCustomerID(req.UserUID)
|
|
if err != nil {
|
|
SetErrorResp(
|
|
c,
|
|
http.StatusInternalServerError,
|
|
gin.H{"error": fmt.Sprintf("failed to get user stripe customer id: %v", err)},
|
|
)
|
|
return err
|
|
}
|
|
if customerID != "" {
|
|
paymentReq.CustomerID = &customerID
|
|
} else {
|
|
dao.Logger.Infof("get user stripe customer id empty: %v", req.UserUID)
|
|
}
|
|
|
|
// Create Stripe subscription session
|
|
stripeResp, err := services.StripeServiceInstance.CreateWorkspaceSubscriptionSession(
|
|
paymentReq,
|
|
*price.StripePrice,
|
|
&transaction,
|
|
)
|
|
if err != nil {
|
|
SetErrorResp(
|
|
c,
|
|
http.StatusInternalServerError,
|
|
gin.H{"error": fmt.Sprintf("failed to create Stripe session: %v", err)},
|
|
)
|
|
return err
|
|
}
|
|
|
|
// Create payment order
|
|
paymentOrder := &types.PaymentOrder{
|
|
ID: transaction.PayID,
|
|
PaymentRaw: types.PaymentRaw{
|
|
UserUID: req.UserUID,
|
|
Amount: transaction.Amount,
|
|
Method: req.PayMethod,
|
|
RegionUID: dao.DBClient.GetLocalRegion().UID,
|
|
TradeNO: paymentReq.RequestID,
|
|
CodeURL: stripeResp.URL,
|
|
Type: types.PaymentTypeSubscription,
|
|
ChargeSource: types.ChargeSourceStripe,
|
|
Stripe: &types.StripePay{
|
|
SessionID: stripeResp.SessionID,
|
|
},
|
|
},
|
|
Status: types.PaymentOrderStatusPending,
|
|
}
|
|
|
|
if err := tx.Create(paymentOrder).Error; err != nil {
|
|
SetErrorResp(
|
|
c,
|
|
http.StatusInternalServerError,
|
|
gin.H{"error": fmt.Sprintf("failed to create payment order: %v", err)},
|
|
)
|
|
return err
|
|
}
|
|
|
|
// Return success response with redirect URL
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"redirectUrl": stripeResp.URL,
|
|
"payID": transaction.PayID,
|
|
"success": true,
|
|
})
|
|
return nil
|
|
}
|
|
|
|
// processBalancePaymentInTransaction handles balance payment within existing transaction
|
|
func processBalancePaymentInTransaction(
|
|
tx *gorm.DB,
|
|
c *gin.Context,
|
|
req *helper.WorkspaceSubscriptionOperatorReq,
|
|
transaction types.WorkspaceSubscriptionTransaction,
|
|
) error {
|
|
// Generate payment ID if not provided
|
|
if transaction.PayID == "" {
|
|
paymentID, err := gonanoid.New(12)
|
|
if err != nil {
|
|
SetErrorResp(
|
|
c,
|
|
http.StatusInternalServerError,
|
|
gin.H{"error": fmt.Sprintf("failed to create payment id: %v", err)},
|
|
)
|
|
return err
|
|
}
|
|
transaction.PayID = paymentID
|
|
}
|
|
|
|
// Check account balance
|
|
var account types.Account
|
|
if err := tx.Where(types.Account{UserUID: req.UserUID}).First(&account).Error; err != nil {
|
|
SetErrorResp(
|
|
c,
|
|
http.StatusInternalServerError,
|
|
gin.H{"error": fmt.Sprintf("failed to get account: %v", err)},
|
|
)
|
|
return err
|
|
}
|
|
if account.Balance-account.DeductionBalance < transaction.Amount {
|
|
SetErrorResp(c, http.StatusBadRequest, gin.H{"error": "insufficient balance"})
|
|
return errors.New("insufficient balance")
|
|
}
|
|
|
|
// Create workspace subscription transaction
|
|
transaction.PayStatus = types.SubscriptionPayStatusPaid
|
|
transaction.Status = types.SubscriptionTransactionStatusCompleted
|
|
if err := dao.DBClient.CreateWorkspaceSubscriptionTransaction(tx, &transaction); err != nil {
|
|
SetErrorResp(
|
|
c,
|
|
http.StatusInternalServerError,
|
|
gin.H{
|
|
"error": fmt.Sprintf(
|
|
"failed to create workspace subscription transaction: %v",
|
|
err,
|
|
),
|
|
},
|
|
)
|
|
return err
|
|
}
|
|
|
|
// Create payment record
|
|
payment := types.Payment{
|
|
ID: transaction.PayID,
|
|
PaymentRaw: types.PaymentRaw{
|
|
UserUID: req.UserUID,
|
|
Amount: transaction.Amount,
|
|
Method: req.PayMethod,
|
|
RegionUID: dao.DBClient.GetLocalRegion().UID,
|
|
Type: types.PaymentTypeSubscription,
|
|
ChargeSource: types.ChargeSourceBalance,
|
|
TradeNO: transaction.PayID,
|
|
Status: types.PaymentStatusPAID,
|
|
},
|
|
}
|
|
if err := tx.Create(&payment).Error; err != nil {
|
|
SetErrorResp(
|
|
c,
|
|
http.StatusInternalServerError,
|
|
gin.H{"error": fmt.Sprintf("failed to save payment: %v", err)},
|
|
)
|
|
return err
|
|
}
|
|
|
|
// Deduct balance using the proper helper function
|
|
if err := cockroach.AddDeductionAccount(tx, req.UserUID, transaction.Amount); err != nil {
|
|
SetErrorResp(
|
|
c,
|
|
http.StatusInternalServerError,
|
|
gin.H{"error": fmt.Sprintf("failed to deduct balance: %v", err)},
|
|
)
|
|
return err
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"success": true,
|
|
"message": "Payment processed successfully",
|
|
})
|
|
return nil
|
|
}
|
|
|
|
// TODO 待完成
|
|
// processNoPaymentOperationInTransaction handles operations that don't require payment within existing transaction
|
|
func processNoPaymentOperationInTransaction(
|
|
tx *gorm.DB,
|
|
c *gin.Context,
|
|
subscription *types.WorkspaceSubscription,
|
|
_ *helper.WorkspaceSubscriptionOperatorReq,
|
|
transaction types.WorkspaceSubscriptionTransaction,
|
|
) error {
|
|
// Ensure payStatus is set to no_need for operations without payment
|
|
if transaction.PayStatus == "" {
|
|
transaction.PayStatus = types.SubscriptionPayStatusNoNeed
|
|
}
|
|
|
|
// Set appropriate status based on operator
|
|
switch transaction.Operator {
|
|
// TODO 非Free用户关闭需要将 workspaceSubscription.CancelAtPeriodEnd改为 true,并且关闭stripe订阅,订阅状态修改为cancel状态(目前不提供主动cancel)
|
|
// 降级则创建开始时间为 workspaceSubscription.CurrentPeriodEndAt的pending状态的操作降级的workspaceSubscriptionTransaction
|
|
// 等待workspaceSubscription.CurrentPeriodEndAt结束后处理
|
|
case types.SubscriptionTransactionTypeDowngraded, types.SubscriptionTransactionTypeCanceled:
|
|
// These operations take effect later, so keep as pending
|
|
transaction.Status = types.SubscriptionTransactionStatusPending
|
|
transaction.StartAt = subscription.CurrentPeriodEndAt.Add(
|
|
-1 * time.Hour,
|
|
) // Buffer time before period end
|
|
if subscription.PayMethod == types.PaymentMethodStripe && subscription.Stripe != nil &&
|
|
subscription.Stripe.SubscriptionID != "" {
|
|
currentPeriodEnd, err := services.StripeServiceInstance.GetSubscriptionCurrentPeriodEnd(
|
|
subscription.Stripe.SubscriptionID,
|
|
)
|
|
if err != nil {
|
|
SetErrorResp(
|
|
c,
|
|
http.StatusInternalServerError,
|
|
gin.H{
|
|
"error": fmt.Sprintf(
|
|
"failed to get Stripe subscription current period end: %v",
|
|
err,
|
|
),
|
|
},
|
|
)
|
|
return err
|
|
}
|
|
transaction.StartAt = currentPeriodEnd.Add(
|
|
-1 * time.Hour,
|
|
) // Buffer time before period end
|
|
transaction.PayStatus = types.SubscriptionPayStatusUnpaid
|
|
}
|
|
default:
|
|
// Immediate operations (like free plan creation)
|
|
transaction.Status = types.SubscriptionTransactionStatusCompleted
|
|
}
|
|
|
|
fmt.Printf("Processing operation: %#+v\n", transaction)
|
|
// Create workspace subscription transaction
|
|
if err := dao.DBClient.CreateWorkspaceSubscriptionTransaction(tx, &transaction); err != nil {
|
|
SetErrorResp(
|
|
c,
|
|
http.StatusInternalServerError,
|
|
gin.H{
|
|
"error": fmt.Sprintf(
|
|
"failed to create workspace subscription transaction: %v",
|
|
err,
|
|
),
|
|
},
|
|
)
|
|
return err
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"success": true,
|
|
"message": fmt.Sprintf(
|
|
"Workspace subscription %s operation completed successfully",
|
|
transaction.Operator,
|
|
),
|
|
})
|
|
return nil
|
|
}
|
|
|
|
// NewWorkspaceSubscriptionNotifyHandler handles Stripe webhook notifications
|
|
// @Summary Handle Stripe webhook for workspace subscription
|
|
// @Description Handle Stripe webhook notifications for workspace subscription events
|
|
// @Tags WorkspaceSubscription
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Router /payment/v1alpha1/workspace-subscription/notify [post]
|
|
func NewWorkspaceSubscriptionNotifyHandler(c *gin.Context) {
|
|
if services.StripeServiceInstance == nil {
|
|
logrus.Error("Stripe service not initialized")
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Stripe service not configured"})
|
|
return
|
|
}
|
|
|
|
payload, err := c.GetRawData()
|
|
if err != nil {
|
|
logrus.Errorf("Failed to get raw data: %v", err)
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "failed to get raw data"})
|
|
return
|
|
}
|
|
|
|
signature := c.GetHeader("Stripe-Signature")
|
|
event, err := services.StripeServiceInstance.HandleWebhook(payload, signature)
|
|
if err != nil {
|
|
logrus.Errorf("Failed to handle webhook: %v", err)
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid webhook signature"})
|
|
return
|
|
}
|
|
|
|
logrus.Infof("Received Stripe workspace subscription event: %s", event.Type)
|
|
|
|
// Process workspace subscription webhook events
|
|
err = processWorkspaceSubscriptionWebhookEvent(event)
|
|
if err != nil {
|
|
// logrus.Errorf("Failed to process workspace subscription webhook event %s: %v", event.Type, err)
|
|
dao.Logger.Errorf(
|
|
"Failed to process workspace subscription webhook %s event : %v, err: %v",
|
|
event.Type,
|
|
event,
|
|
err,
|
|
)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to process webhook"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"received": true})
|
|
}
|
|
|
|
// processWorkspaceSubscriptionWebhookEvent processes webhook events with database operations
|
|
// Following the pattern of subscription.go's processSubscriptionPayResult
|
|
func processWorkspaceSubscriptionWebhookEvent(event *stripe.Event) error {
|
|
switch event.Type {
|
|
// TODO First payment: Store subscription id + customer id in WorkspaceSubscription
|
|
case "checkout.session.completed":
|
|
// Handle initial subscription payment success
|
|
// return handleWorkspaceSubscriptionSessionCompleted(event)
|
|
|
|
// TODO When the payment is successful, it will be sent at each billing interval. It is necessary to consider how to handle it if it is sent repeatedly within the current interval
|
|
// 1. 创建 renewal WorkspaceSubscriptionTransaction 状态为已支付,支付方式为 Stripe,并创建关联的payment
|
|
case "invoice.paid":
|
|
// Handle recurring subscription payment success
|
|
return handleWorkspaceSubscriptionInvoicePaid(event)
|
|
// return nil
|
|
// TODO 付费失败时每个计费间隔发送, 需要考虑如果当前间隔内重复发送如何处理
|
|
// 1. try to pay with your balance
|
|
// 2. If the balance is insufficient, send a payment failure notification
|
|
// 3. If the Balance enough, update the renewal WorkspaceSubscriptionTransaction status as paid, payment for the Balance, and create the related payment
|
|
case "invoice.payment_failed":
|
|
// Handle recurring subscription renewal failure
|
|
return handleWorkspaceSubscriptionRenewalFailure(event)
|
|
// case "invoice.payment_succeeded":
|
|
// // Handle recurring subscription renewal success
|
|
// return handleWorkspaceSubscriptionRenewalSuccess(event)
|
|
// case "customer.subscription.deleted":
|
|
// // Handle subscription cancellation/closure
|
|
// return handleWorkspaceSubscriptionClosure(event)
|
|
// case "checkout.session.expired":
|
|
// // Handle payment session timeout/expiry
|
|
// return handleWorkspaceSubscriptionPaymentFailure(event)
|
|
case "customer.subscription.updated":
|
|
// Handle subscription schedule updates
|
|
case "customer.subscription.deleted":
|
|
return handleWorkspaceSubscriptionDeleted(event)
|
|
case "checkout.session.expired":
|
|
return handleWorkspaceSubscriptionSessionExpired(event)
|
|
default:
|
|
logrus.Infof("Unhandled workspace subscription webhook event type: %s", event.Type)
|
|
return nil
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func checkIsLocalEvent(event any) (bool, error) {
|
|
switch e := event.(type) {
|
|
// case *stripe.Invoice:
|
|
// if e.Metadata == nil || e.Metadata["region_domain"] == "" {
|
|
// return false, fmt.Errorf("invoice has no associated region domain")
|
|
// }
|
|
// if e.Metadata["region_domain"] != dao.DBClient.GetLocalRegion().Domain {
|
|
// return false, nil
|
|
// }
|
|
// return true, nil
|
|
case *stripe.Subscription:
|
|
if e.Metadata == nil || e.Metadata["region_domain"] == "" {
|
|
// TODO 兼容老数据
|
|
dao.Logger.Infof("Subscription has no associated region domain, assuming local for backward compatibility")
|
|
return false, nil
|
|
}
|
|
if e.Metadata["region_domain"] != dao.DBClient.GetLocalRegion().Domain {
|
|
return false, nil
|
|
}
|
|
return true, nil
|
|
case *stripe.CheckoutSession:
|
|
if e.Metadata == nil || e.Metadata["region_domain"] == "" {
|
|
return false, nil
|
|
}
|
|
if e.Metadata["region_domain"] != dao.DBClient.GetLocalRegion().Domain {
|
|
return false, nil
|
|
}
|
|
return true, nil
|
|
default:
|
|
return false, errors.New("unsupported event data type")
|
|
}
|
|
}
|
|
|
|
func updateWorkspaceSubscriptionQuota(planName, workspace string) error {
|
|
res := dao.WorkspacePlanResQuota[planName].DeepCopy()
|
|
// TODO 需要考虑默认的其他quota限制,nodeport等
|
|
nsQuota := resources.GetDefaultResourceQuota(workspace, "quota-"+workspace)
|
|
for defaultRs, quantity := range nsQuota.Spec.Hard {
|
|
if _, ok := res[defaultRs]; ok {
|
|
continue
|
|
}
|
|
res[defaultRs] = quantity.DeepCopy()
|
|
}
|
|
nsQuota.Spec.Hard = res
|
|
_, err := controllerutil.CreateOrUpdate(
|
|
context.Background(),
|
|
dao.K8sManager.GetClient(),
|
|
nsQuota,
|
|
func() error {
|
|
nsQuota.Spec.Hard = res
|
|
return nil
|
|
},
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create or update resource quota: %w", err)
|
|
}
|
|
|
|
if err = updateDebtNamespaceStatus(context.Background(), dao.K8sManager.GetClient(), ResumeDebtNamespaceAnnoStatus, []string{workspace}); err != nil {
|
|
return fmt.Errorf("failed to update namespace status: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Helper function to finalize successful payment processing
|
|
// This abstracts common logic for updating subscription, quota, and traffic
|
|
func finalizeWorkspaceSubscriptionSuccess(
|
|
tx *gorm.DB,
|
|
workspaceSubscription *types.WorkspaceSubscription,
|
|
wsTransaction *types.WorkspaceSubscriptionTransaction,
|
|
payment *types.Payment,
|
|
) error {
|
|
if wsTransaction.PayID == "" {
|
|
wsTransaction.PayID = payment.ID
|
|
if payment.ID == "" {
|
|
paymentID, err := gonanoid.New(12)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create payment id: %w", err)
|
|
}
|
|
wsTransaction.PayID = paymentID
|
|
payment.ID = paymentID
|
|
}
|
|
}
|
|
var workspaceSubscriptionID uuid.UUID
|
|
if workspaceSubscription != nil {
|
|
workspaceSubscriptionID = workspaceSubscription.ID
|
|
workspaceSubscription.CancelAtPeriodEnd = false
|
|
wsTransaction.OldPlanStatus = workspaceSubscription.Status
|
|
workspaceSubscription.Status = types.SubscriptionStatusNormal
|
|
} else {
|
|
workspaceSubscriptionID = uuid.New()
|
|
wsTransaction.OldPlanStatus = types.SubscriptionStatusNormal
|
|
}
|
|
payment.WorkspaceSubscriptionID = &workspaceSubscriptionID
|
|
wsTransaction.Status = types.SubscriptionTransactionStatusCompleted
|
|
wsTransaction.Amount = payment.Amount
|
|
wsTransaction.PayStatus = types.SubscriptionPayStatusPaid
|
|
// Create or update transaction
|
|
if wsTransaction.Operator == types.SubscriptionTransactionTypeCreated ||
|
|
wsTransaction.Operator == types.SubscriptionTransactionTypeUpgraded ||
|
|
wsTransaction.Operator == types.SubscriptionTransactionTypeDowngraded {
|
|
// For initial, transaction already exists, just ensure status
|
|
if err := tx.Save(wsTransaction).Error; err != nil {
|
|
return fmt.Errorf("failed to update workspace subscription transaction: %w", err)
|
|
}
|
|
} else {
|
|
// For renewal, create new transaction
|
|
if err := dao.DBClient.CreateWorkspaceSubscriptionTransaction(tx, wsTransaction); err != nil {
|
|
return fmt.Errorf("failed to create workspace subscription transaction: %w", err)
|
|
}
|
|
}
|
|
|
|
if payment.Amount > 0 {
|
|
// Create payment
|
|
if err := tx.Create(payment).Error; err != nil {
|
|
return fmt.Errorf("failed to create payment record: %w", err)
|
|
}
|
|
}
|
|
|
|
// Update or create workspace subscription
|
|
oldPlanName := ""
|
|
if workspaceSubscription != nil {
|
|
oldPlanName = workspaceSubscription.PlanName
|
|
workspaceSubscription.PlanName = wsTransaction.NewPlanName
|
|
workspaceSubscription.PayStatus = types.SubscriptionPayStatusPaid
|
|
workspaceSubscription.TrafficStatus = types.WorkspaceTrafficStatusActive
|
|
workspaceSubscription.PayMethod = payment.Method
|
|
if payment.Stripe != nil {
|
|
workspaceSubscription.Stripe = payment.Stripe
|
|
}
|
|
} else {
|
|
// Create new for initial if not exists
|
|
workspaceSubscription = &types.WorkspaceSubscription{
|
|
ID: workspaceSubscriptionID,
|
|
PlanName: wsTransaction.NewPlanName,
|
|
Workspace: wsTransaction.Workspace,
|
|
RegionDomain: wsTransaction.RegionDomain,
|
|
UserUID: wsTransaction.UserUID,
|
|
Status: types.SubscriptionStatusNormal,
|
|
TrafficStatus: types.WorkspaceTrafficStatusActive,
|
|
PayStatus: types.SubscriptionPayStatusPaid,
|
|
PayMethod: payment.Method,
|
|
CurrentPeriodStartAt: time.Now().UTC(),
|
|
CurrentPeriodEndAt: time.Now().UTC().AddDate(0, 1, 0), // Monthly
|
|
CreateAt: time.Now().UTC(),
|
|
ExpireAt: stripe.Time(time.Now().UTC().AddDate(0, 1, 0)),
|
|
}
|
|
if payment.Stripe != nil {
|
|
workspaceSubscription.Stripe = payment.Stripe
|
|
}
|
|
}
|
|
|
|
// Set period for renewal
|
|
if wsTransaction.Operator == types.SubscriptionTransactionTypeRenewed {
|
|
workspaceSubscription.CurrentPeriodStartAt = time.Now().UTC()
|
|
workspaceSubscription.CurrentPeriodEndAt = time.Now().UTC().AddDate(0, 1, 0)
|
|
workspaceSubscription.ExpireAt = stripe.Time(workspaceSubscription.CurrentPeriodEndAt)
|
|
}
|
|
|
|
if err := tx.Save(workspaceSubscription).Error; err != nil {
|
|
return fmt.Errorf("failed to save workspace subscription: %w", err)
|
|
}
|
|
|
|
// Update resource quota for create or upgrade
|
|
if wsTransaction.Operator != types.SubscriptionTransactionTypeRenewed {
|
|
if err := updateWorkspaceSubscriptionQuota(wsTransaction.NewPlanName, workspaceSubscription.Workspace); err != nil {
|
|
return fmt.Errorf("failed to create or update resource quota: %w", err)
|
|
}
|
|
}
|
|
|
|
// Add traffic package
|
|
plan, err := dao.DBClient.GetWorkspaceSubscriptionPlan(wsTransaction.NewPlanName)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get workspace subscription plan: %w", err)
|
|
}
|
|
traffic := plan.Traffic
|
|
aiQuota := plan.AIQuota
|
|
if (wsTransaction.Operator == types.SubscriptionTransactionTypeUpgraded || wsTransaction.Operator == types.SubscriptionTransactionTypeRenewed) &&
|
|
oldPlanName != "" &&
|
|
oldPlanName != types.FreeSubscriptionPlanName &&
|
|
oldPlanName != wsTransaction.NewPlanName {
|
|
oldPlan, err := dao.DBClient.GetWorkspaceSubscriptionPlan(oldPlanName)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get old workspace subscription plan: %w", err)
|
|
}
|
|
traffic -= oldPlan.Traffic
|
|
if traffic < 0 {
|
|
traffic = 0
|
|
}
|
|
aiQuota -= oldPlan.AIQuota
|
|
if aiQuota < 0 {
|
|
aiQuota = 0
|
|
}
|
|
}
|
|
if wsTransaction.Period == "" {
|
|
wsTransaction.Period = types.SubscriptionPeriodMonthly
|
|
}
|
|
if wsTransaction.Operator != types.SubscriptionTransactionTypeDowngraded {
|
|
if traffic > 0 {
|
|
period, err := types.ParsePeriod(wsTransaction.Period)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid subscription period: %w", err)
|
|
}
|
|
err = helper.AddTrafficPackage(
|
|
tx,
|
|
dao.K8sManager.GetClient(),
|
|
workspaceSubscription,
|
|
plan,
|
|
time.Now().Add(period),
|
|
types.WorkspaceTrafficFromWorkspaceSubscription,
|
|
wsTransaction.ID.String(),
|
|
)
|
|
// err = dao.AddWorkspaceSubscriptionTrafficPackage(tx, workspaceSubscription.ID, traffic, workspaceSubscription.CurrentPeriodEndAt, types.WorkspaceTrafficFromWorkspaceSubscription, wsTransaction.ID.String())
|
|
if err != nil {
|
|
return fmt.Errorf("failed to add traffic package: %w", err)
|
|
}
|
|
}
|
|
if aiQuota > 0 {
|
|
period, err := types.ParsePeriod(wsTransaction.Period)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid subscription period: %w", err)
|
|
}
|
|
err = cockroach.AddWorkspaceSubscriptionAIQuotaPackage(
|
|
tx,
|
|
workspaceSubscription.ID,
|
|
plan.AIQuota,
|
|
time.Now().Add(period),
|
|
types.PKGFromWorkspaceSubscription,
|
|
wsTransaction.ID.String(),
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to add AI quota package: %w", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
if err := updateWorkspaceSubscriptionNamespaceStatus(workspaceSubscription.Workspace); err != nil {
|
|
// dao.Logger.Errorf("Failed to update workspace subscription namespace annotation: %v", err)
|
|
return fmt.Errorf("failed to update workspace subscription namespace annotation: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// updateWorkspaceSubscriptionNamespaceStatus updates the workspace subscription status annotation on namespace
|
|
func updateWorkspaceSubscriptionNamespaceStatus(workspace string) error {
|
|
ctx := context.Background()
|
|
|
|
// Use retry.RetryOnConflict to handle conflicts
|
|
return retry.RetryOnConflict(retry.DefaultRetry, func() error {
|
|
// Fetch the latest namespace object
|
|
ns := &corev1.Namespace{}
|
|
if err := dao.K8sManager.GetClient().Get(ctx, types2.NamespacedName{Name: workspace}, ns); err != nil {
|
|
if k8serrors.IsNotFound(err) {
|
|
logrus.Info(
|
|
"Namespace not found, skipping workspace subscription annotation update",
|
|
"namespace",
|
|
workspace,
|
|
)
|
|
return nil // Skip if namespace doesn't exist
|
|
}
|
|
return fmt.Errorf("failed to get namespace %s: %w", workspace, err)
|
|
}
|
|
|
|
// Initialize annotations if needed
|
|
if ns.Annotations == nil {
|
|
ns.Annotations = make(map[string]string)
|
|
}
|
|
|
|
// Check if the annotation already matches the desired status
|
|
if ns.Annotations[types.WorkspaceSubscriptionStatusAnnoKey] == types.NormalDebtNamespaceAnnoStatus ||
|
|
ns.Status.Phase == corev1.NamespaceTerminating {
|
|
return nil
|
|
}
|
|
|
|
// Update the annotation
|
|
ns.Annotations[types.WorkspaceSubscriptionStatusAnnoKey] = types.NormalDebtNamespaceAnnoStatus
|
|
|
|
// Attempt to update the namespace
|
|
if err := dao.K8sManager.GetClient().Update(ctx, ns); err != nil {
|
|
return fmt.Errorf("failed to update namespace annotation: %w", err)
|
|
}
|
|
|
|
logrus.Infof(
|
|
"Successfully updated workspace subscription status annotation for namespace %s",
|
|
workspace,
|
|
)
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// Reimplemented handleWorkspaceSubscriptionRenewalFailure
|
|
func handleWorkspaceSubscriptionRenewalFailure(event *stripe.Event) error {
|
|
// Parse invoice data
|
|
sessionData, err := services.StripeServiceInstance.ParseWebhookEventData(event)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to parse webhook data: %w", err)
|
|
}
|
|
|
|
invoice, ok := sessionData.(*stripe.Invoice)
|
|
if !ok {
|
|
return errors.New("invalid invoice data type")
|
|
}
|
|
if invoice.Parent == nil || invoice.Parent.SubscriptionDetails == nil ||
|
|
invoice.Parent.SubscriptionDetails.Subscription == nil {
|
|
return errors.New("invoice has no associated subscription")
|
|
}
|
|
|
|
subscriptionID := invoice.Parent.SubscriptionDetails.Subscription.ID
|
|
subscription, err := services.StripeServiceInstance.GetSubscription(subscriptionID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get Stripe subscription: %w", err)
|
|
}
|
|
|
|
isLocal, err := checkIsLocalEvent(subscription)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to check event region: %w", err)
|
|
}
|
|
if !isLocal {
|
|
return nil
|
|
}
|
|
logrus.Infof("Processing invoice payment failure for subscription: %s", subscription.ID)
|
|
|
|
// Get metadata
|
|
workspace := subscription.Metadata["workspace"]
|
|
regionDomain := subscription.Metadata["region_domain"]
|
|
userUIDStr := subscription.Metadata["user_uid"]
|
|
newPlanName := subscription.Metadata["plan_name"]
|
|
paymentID := subscription.Metadata["payment_id"]
|
|
transactionID := subscription.Metadata["transaction_id"]
|
|
operator := subscription.Metadata["subscription_operator"]
|
|
|
|
if workspace == "" || regionDomain == "" || userUIDStr == "" || newPlanName == "" {
|
|
return errors.New("missing required metadata in session")
|
|
}
|
|
|
|
// Get workspace subscription
|
|
workspaceSubscription, err := dao.DBClient.GetWorkspaceSubscription(workspace, regionDomain)
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return errors.New("workspace subscription not found for renewal failure")
|
|
}
|
|
return fmt.Errorf("failed to get workspace subscription: %w", err)
|
|
}
|
|
if workspaceSubscription != nil &&
|
|
workspaceSubscription.Status == types.SubscriptionStatusDeleted {
|
|
_, err := services.StripeServiceInstance.CancelSubscription(subscriptionID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to cancel subscription for deleted workspace: %w", err)
|
|
}
|
|
dao.Logger.Infof(
|
|
"renewalFailure: subscription paid for deleted workspace %s/%s, subscription %s canceled",
|
|
workspace,
|
|
regionDomain,
|
|
subscriptionID,
|
|
)
|
|
return nil
|
|
}
|
|
userUID := workspaceSubscription.UserUID
|
|
|
|
isInitialSubscription := invoice.BillingReason == "subscription_create"
|
|
isRenewSubscription := invoice.BillingReason == "subscription_cycle"
|
|
isUpdateSubscription := invoice.BillingReason == "subscription_update"
|
|
failureReason := fmt.Sprintf(
|
|
"Stripe payment failed for invoice %s: %s",
|
|
invoice.ID,
|
|
invoice.LastFinalizationError.Error(),
|
|
)
|
|
|
|
notifyEventData := &usernotify.WorkspaceSubscriptionEventData{
|
|
WorkspaceName: workspace,
|
|
Domain: regionDomain,
|
|
// Operator: types.SubscriptionOperator(operator),
|
|
PayStatus: types.SubscriptionPayStatusFailed,
|
|
// ExpirationDate: fmt.Sprintf("%s-%s", workspaceSubscription.CurrentPeriodStartAt.Format("2006-01-02"), workspaceSubscription.CurrentPeriodEndAt.Format("2006-01-02")),
|
|
ExpirationDate: fmt.Sprintf("%s - %s",
|
|
workspaceSubscription.CurrentPeriodStartAt.Format("2006.1.2"),
|
|
workspaceSubscription.CurrentPeriodEndAt.Format("2006.1.2")),
|
|
Amount: math.Ceil(float64(invoice.AmountDue) / float64(100)),
|
|
ErrorReason: invoice.LastFinalizationError.Error(),
|
|
}
|
|
switch {
|
|
case isInitialSubscription:
|
|
notifyEventData.Operator = types.SubscriptionTransactionTypeCreated
|
|
case isRenewSubscription:
|
|
notifyEventData.Operator = types.SubscriptionTransactionTypeRenewed
|
|
case isUpdateSubscription:
|
|
paymentID = subscription.Metadata["last_payment_id"]
|
|
// operator = subscription.Metadata["last_operator"]
|
|
newPlanName = subscription.Metadata["new_plan_name"]
|
|
notifyEventData.Operator = types.SubscriptionOperator(operator)
|
|
default:
|
|
logrus.Errorf(
|
|
"handleWorkspaceSubscriptionRenewalFailure unsupported billing reason for payment failure: %s",
|
|
invoice.BillingReason,
|
|
)
|
|
return fmt.Errorf("unsupported billing reason: %s", invoice.BillingReason)
|
|
}
|
|
nr, err := dao.DBClient.GetNotificationRecipient(userUID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get notification recipient for user %s: %w", userUID, err)
|
|
}
|
|
dao.UserContactProvider.SetUserContact(userUID, nr)
|
|
defer dao.UserContactProvider.RemoveUserContact(userUID)
|
|
|
|
// Database operations
|
|
return dao.DBClient.GlobalTransactionHandler(func(tx *gorm.DB) error {
|
|
if isInitialSubscription || isUpdateSubscription {
|
|
// Update transaction to failed
|
|
var wsTransaction types.WorkspaceSubscriptionTransaction
|
|
switch types.SubscriptionOperator(operator) {
|
|
case types.SubscriptionTransactionTypeCreated:
|
|
if paymentID == "" {
|
|
return fmt.Errorf("missing payment_id for %s failure", invoice.BillingReason)
|
|
}
|
|
if err := tx.Where("pay_id = ?", paymentID).First(&wsTransaction).Error; err != nil {
|
|
return fmt.Errorf("transaction not found: %w", err)
|
|
}
|
|
case types.SubscriptionTransactionTypeUpgraded,
|
|
types.SubscriptionTransactionTypeDowngraded:
|
|
if transactionID == "" {
|
|
return fmt.Errorf(
|
|
"missing transaction_id for %s failure",
|
|
invoice.BillingReason,
|
|
)
|
|
}
|
|
if err := tx.Where("id = ?", transactionID).First(&wsTransaction).Error; err != nil {
|
|
return fmt.Errorf("transaction not found: %w", err)
|
|
}
|
|
default:
|
|
return fmt.Errorf(
|
|
"unsupported operator for %s failure: %s",
|
|
invoice.BillingReason,
|
|
operator,
|
|
)
|
|
}
|
|
wsTransaction.PayStatus = types.SubscriptionPayStatusFailed
|
|
wsTransaction.Status = types.SubscriptionTransactionStatusFailed
|
|
wsTransaction.StatusDesc = failureReason
|
|
if err := tx.Save(&wsTransaction).Error; err != nil {
|
|
return fmt.Errorf("failed to update transaction: %w", err)
|
|
}
|
|
|
|
// Update payment order to failed if exists (for initial/upgrade)
|
|
var paymentOrder types.PaymentOrder
|
|
if err := tx.Where("id = ?", paymentID).First(&paymentOrder).Error; err == nil {
|
|
paymentOrder.Status = types.PaymentOrderStatusFailed
|
|
if err := tx.Save(&paymentOrder).Error; err != nil {
|
|
return fmt.Errorf("failed to update payment order: %w", err)
|
|
}
|
|
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return fmt.Errorf("failed to query payment order: %w", err)
|
|
}
|
|
// For upgrade failure, also revert plan changes if applied prematurely
|
|
if isUpdateSubscription && workspaceSubscription.PlanName == newPlanName {
|
|
logrus.Infof(
|
|
"Reverting plan change due to upgrade payment failure for %s/%s, old plan: %s",
|
|
workspace,
|
|
regionDomain,
|
|
wsTransaction.OldPlanName,
|
|
)
|
|
oldPlanName := wsTransaction.OldPlanName
|
|
workspaceSubscription.PlanName = oldPlanName
|
|
workspaceSubscription.PayStatus = types.SubscriptionPayStatusFailed
|
|
if err := tx.Save(&workspaceSubscription).Error; err != nil {
|
|
return fmt.Errorf("failed to revert upgrade plan: %w", err)
|
|
}
|
|
// Revert quota and traffic if needed (call revert functions)
|
|
if err := revertWorkspaceSubscriptionQuota(oldPlanName, workspace); err != nil {
|
|
logrus.Errorf("failed to revert quota for upgrade failure: %v", err)
|
|
}
|
|
// TODO: Revert traffic package if added
|
|
}
|
|
|
|
notifyEventData.OldPlanName = wsTransaction.OldPlanName
|
|
notifyEventData.NewPlanName = wsTransaction.NewPlanName
|
|
logrus.Warnf(
|
|
"%s payment failed for %s/%s",
|
|
invoice.BillingReason,
|
|
workspace,
|
|
regionDomain,
|
|
)
|
|
} else if isRenewSubscription {
|
|
// TODO 续费失败的情况 先判断当前状态是否已经续费,如果已经续费成功则不处理
|
|
if workspaceSubscription.PayStatus == types.SubscriptionPayStatusPaid && workspaceSubscription.CurrentPeriodEndAt.After(time.Now().UTC()) {
|
|
logrus.Infof("Subscription already active for %s/%s, skipping renewal failure handling", workspace, regionDomain)
|
|
return nil
|
|
}
|
|
|
|
// Renewal failure - try balance
|
|
var account types.Account
|
|
if err := tx.Model(&types.Account{}).Where(`"userUid" = ?`, userUID).First(&account).Error; err != nil {
|
|
return fmt.Errorf("failed to get account: %w", err)
|
|
}
|
|
|
|
if account.Balance-account.DeductionBalance >= invoice.AmountDue*10_000 { // Assuming AmountDue is in cents, convert to dollars
|
|
// Balance payment success
|
|
_payID, err := gonanoid.New(12)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create payment id: %w", err)
|
|
}
|
|
|
|
// Prepare payment
|
|
payment := types.Payment{
|
|
ID: _payID,
|
|
PaymentRaw: types.PaymentRaw{
|
|
UserUID: userUID,
|
|
RegionUID: dao.DBClient.GetLocalRegion().UID,
|
|
CreatedAt: time.Now().UTC(),
|
|
Method: helper.BALANCE,
|
|
Amount: invoice.AmountDue * 10_000,
|
|
TradeNO: _payID,
|
|
Type: types.PaymentTypeSubscription,
|
|
ChargeSource: types.ChargeSourceBalance,
|
|
Status: types.PaymentStatusPAID,
|
|
WorkspaceSubscriptionID: &workspaceSubscription.ID,
|
|
Message: fmt.Sprintf("Balance fallback for failed Stripe renewal on workspace %s/%s", workspace, regionDomain),
|
|
},
|
|
}
|
|
|
|
// Deduct balance
|
|
if err := cockroach.AddDeductionAccount(tx, userUID, invoice.AmountDue*10_000); err != nil {
|
|
return fmt.Errorf("failed to deduct balance: %w", err)
|
|
}
|
|
|
|
// Prepare transaction
|
|
wsTransaction := types.WorkspaceSubscriptionTransaction{
|
|
ID: uuid.New(),
|
|
From: types.TransactionFromUser,
|
|
Workspace: workspace,
|
|
RegionDomain: regionDomain,
|
|
UserUID: userUID,
|
|
OldPlanName: workspaceSubscription.PlanName,
|
|
NewPlanName: newPlanName,
|
|
OldPlanStatus: workspaceSubscription.Status,
|
|
Operator: types.SubscriptionTransactionTypeRenewed,
|
|
StartAt: time.Now().UTC(),
|
|
CreatedAt: time.Now().UTC(),
|
|
UpdatedAt: time.Now().UTC(),
|
|
PayStatus: types.SubscriptionPayStatusPaid,
|
|
PayID: _payID,
|
|
Period: types.SubscriptionPeriodMonthly,
|
|
Amount: invoice.AmountDue * 10_000,
|
|
}
|
|
|
|
// Finalize using helper (not initial)
|
|
if err := finalizeWorkspaceSubscriptionSuccess(tx, workspaceSubscription, &wsTransaction, &payment); err != nil {
|
|
return err
|
|
}
|
|
|
|
notifyEventData.Operator = types.SubscriptionTransactionTypeRenewed
|
|
notifyEventData.OldPlanName = newPlanName
|
|
notifyEventData.NewPlanName = newPlanName
|
|
notifyEventData.PayStatus = types.SubscriptionPayStatusFailedAndUseBalance
|
|
// TODO 需要考虑用户stripe支付失败最终关闭后的状态,将用户的支付状态修改为Balance支付
|
|
// TODO: Send notification - renewal failed on Stripe, but succeeded with balance deduction
|
|
logrus.Infof("Renewal succeeded with balance for %s/%s", workspace, regionDomain)
|
|
} else {
|
|
// Both payments failed
|
|
workspaceSubscription.PayStatus = types.SubscriptionPayStatusFailed
|
|
workspaceSubscription.Status = types.SubscriptionStatusDebt
|
|
if err := tx.Save(workspaceSubscription).Error; err != nil {
|
|
return fmt.Errorf("failed to update subscription to debt status: %w", err)
|
|
}
|
|
|
|
// //TODO: Mark workspace as debt (e.g., add label or update field)
|
|
if err := updateDebtNamespaceStatus(context.Background(), dao.K8sManager.GetClient(), SuspendDebtNamespaceAnnoStatus, []string{workspace}); err != nil {
|
|
return fmt.Errorf("update namespace status error: %w", err)
|
|
}
|
|
|
|
notifyEventData.Operator = types.SubscriptionTransactionTypeRenewed
|
|
notifyEventData.OldPlanName = newPlanName
|
|
notifyEventData.NewPlanName = newPlanName
|
|
notifyEventData.PayStatus = types.SubscriptionPayStatusFailed
|
|
// TODO: Send notification - renewal failed, please renew manually
|
|
logrus.Warnf("Renewal failed for %s/%s, set to debt status", workspace, regionDomain)
|
|
}
|
|
}
|
|
|
|
// send notifycation
|
|
if _, err = dao.UserNotificationService.HandleWorkspaceSubscriptionEvent(context.Background(), userUID, notifyEventData, types.SubscriptionOperator(operator), []usernotify.NotificationMethod{usernotify.NotificationMethodEmail}); err != nil {
|
|
// logrus.Errorf("failed to send subscription failure notification to user %s: %v", userUID, err)
|
|
dao.Logger.Errorf(
|
|
"failed to send subscription failure notification for user %s: %v",
|
|
userUID,
|
|
err,
|
|
)
|
|
// return fmt.Errorf("failed to send subscription success notification to user %s: %w", userUID, err)
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// Helper function to revert quota for failed upgrades (implement based on your needs)
|
|
func revertWorkspaceSubscriptionQuota(planName, workspace string) error {
|
|
// Revert to old plan quota
|
|
res := dao.WorkspacePlanResQuota[planName].DeepCopy()
|
|
// TODO: Implement quota reversion logic similar to updateWorkspaceSubscriptionQuota but with old plan
|
|
nsQuota := resources.GetDefaultResourceQuota(workspace, "quota-"+workspace)
|
|
for defaultRs, quantity := range nsQuota.Spec.Hard {
|
|
if _, ok := res[defaultRs]; ok {
|
|
continue
|
|
}
|
|
res[defaultRs] = quantity.DeepCopy()
|
|
}
|
|
nsQuota.Spec.Hard = res
|
|
_, err := controllerutil.CreateOrUpdate(
|
|
context.Background(),
|
|
dao.K8sManager.GetClient(),
|
|
nsQuota,
|
|
func() error {
|
|
nsQuota.Spec.Hard = res
|
|
return nil
|
|
},
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to revert resource quota: %w", err)
|
|
}
|
|
// TODO: Revert debt namespace status if needed
|
|
return nil
|
|
}
|
|
|
|
// handleWorkspaceSubscriptionDeleted
|
|
func handleWorkspaceSubscriptionDeleted(event *stripe.Event) error {
|
|
// Parse subscription data from webhook event
|
|
subscriptionData, err := services.StripeServiceInstance.ParseWebhookEventData(event)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to parse webhook data: %w", err)
|
|
}
|
|
|
|
subscription, ok := subscriptionData.(*stripe.Subscription)
|
|
if !ok {
|
|
return errors.New("invalid subscription data type")
|
|
}
|
|
|
|
isLocal, err := checkIsLocalEvent(subscription)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to check event region: %w", err)
|
|
}
|
|
if !isLocal {
|
|
return nil
|
|
}
|
|
logrus.Infof("Processing subscription deletion: %s", subscription.ID)
|
|
|
|
// Get metadata
|
|
workspace := subscription.Metadata["workspace"]
|
|
regionDomain := subscription.Metadata["region_domain"]
|
|
userUIDStr := subscription.Metadata["user_uid"]
|
|
|
|
if workspace == "" || regionDomain == "" || userUIDStr == "" {
|
|
return errors.New("missing required metadata in subscription")
|
|
}
|
|
|
|
userUID, err := uuid.Parse(userUIDStr)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid user UID in metadata: %w", err)
|
|
}
|
|
|
|
// Database operations
|
|
return dao.DBClient.GlobalTransactionHandler(func(tx *gorm.DB) error {
|
|
// Get workspace subscription
|
|
var workspaceSubscription types.WorkspaceSubscription
|
|
if err := tx.Where("workspace = ? AND region_domain = ?", workspace, regionDomain).First(&workspaceSubscription).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
logrus.Warnf(
|
|
"Workspace subscription not found for deletion: %s/%s",
|
|
workspace,
|
|
regionDomain,
|
|
)
|
|
return nil
|
|
}
|
|
return fmt.Errorf("failed to get workspace subscription: %w", err)
|
|
}
|
|
if workspaceSubscription.Status == types.SubscriptionStatusDeleted {
|
|
_, err := services.StripeServiceInstance.CancelSubscription(subscription.ID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to cancel subscription for deleted workspace: %w", err)
|
|
}
|
|
dao.Logger.Infof(
|
|
"handleSubscriptionDeletedEvent: subscription paid for deleted workspace %s/%s, subscription %s canceled",
|
|
workspace,
|
|
regionDomain,
|
|
subscription.ID,
|
|
)
|
|
return nil
|
|
}
|
|
if workspaceSubscription.Status != types.SubscriptionStatusNormal {
|
|
return nil
|
|
}
|
|
if workspaceSubscription.CurrentPeriodEndAt.After(time.Now().UTC()) {
|
|
// set pay method to balance
|
|
workspaceSubscription.PayMethod = helper.BALANCE
|
|
return tx.Save(&workspaceSubscription).Error
|
|
}
|
|
notifyEventData := &usernotify.WorkspaceSubscriptionEventData{
|
|
WorkspaceName: workspace,
|
|
Domain: regionDomain,
|
|
Operator: types.SubscriptionTransactionTypeRenewed,
|
|
OldPlanName: workspaceSubscription.PlanName,
|
|
NewPlanName: workspaceSubscription.PlanName,
|
|
}
|
|
plan, err := dao.DBClient.GetWorkspaceSubscriptionPlan(workspaceSubscription.PlanName)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get workspace subscription plan: %w", err)
|
|
}
|
|
features, err := types.ParseMaxResource(plan.MaxResources, plan.Traffic)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to parse plan features: %w", err)
|
|
}
|
|
notifyEventData.Features = features
|
|
|
|
// Renewal failure - try balance
|
|
var account types.Account
|
|
if err := tx.Where("user_uid = ?", userUID).First(&account).Error; err != nil {
|
|
return fmt.Errorf("failed to get account: %w", err)
|
|
}
|
|
price, err := dao.DBClient.GetWorkspaceSubscriptionPlanPrice(
|
|
workspaceSubscription.PlanName,
|
|
types.SubscriptionPeriodMonthly,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get plan price: %w", err)
|
|
}
|
|
notifyEventData.Amount = float64(price.Price)
|
|
|
|
if account.Balance-account.DeductionBalance >= price.Price {
|
|
// Balance payment success
|
|
_payID, err := gonanoid.New(12)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create payment id: %w", err)
|
|
}
|
|
|
|
// Prepare payment
|
|
payment := types.Payment{
|
|
ID: _payID,
|
|
PaymentRaw: types.PaymentRaw{
|
|
UserUID: userUID,
|
|
RegionUID: dao.DBClient.GetLocalRegion().UID,
|
|
CreatedAt: time.Now().UTC(),
|
|
Method: helper.BALANCE,
|
|
Amount: price.Price,
|
|
TradeNO: _payID,
|
|
Type: types.PaymentTypeSubscription,
|
|
ChargeSource: types.ChargeSourceBalance,
|
|
Status: types.PaymentStatusPAID,
|
|
WorkspaceSubscriptionID: &workspaceSubscription.ID,
|
|
Message: fmt.Sprintf(
|
|
"Balance fallback for failed Stripe renewal on workspace %s/%s",
|
|
workspace,
|
|
regionDomain,
|
|
),
|
|
},
|
|
}
|
|
|
|
// Deduct balance
|
|
if err := cockroach.AddDeductionAccount(tx, userUID, price.Price); err != nil {
|
|
return fmt.Errorf("failed to deduct balance: %w", err)
|
|
}
|
|
|
|
// Prepare transaction
|
|
wsTransaction := types.WorkspaceSubscriptionTransaction{
|
|
ID: uuid.New(),
|
|
From: types.TransactionFromSystem,
|
|
Workspace: workspace,
|
|
RegionDomain: regionDomain,
|
|
UserUID: userUID,
|
|
OldPlanName: workspaceSubscription.PlanName,
|
|
NewPlanName: workspaceSubscription.PlanName,
|
|
OldPlanStatus: workspaceSubscription.Status,
|
|
Operator: types.SubscriptionTransactionTypeRenewed,
|
|
StartAt: time.Now().UTC(),
|
|
CreatedAt: time.Now().UTC(),
|
|
UpdatedAt: time.Now().UTC(),
|
|
PayStatus: types.SubscriptionPayStatusPaid,
|
|
PayID: _payID,
|
|
Period: types.SubscriptionPeriodMonthly,
|
|
Amount: price.Price,
|
|
}
|
|
|
|
// Finalize using helper (not initial)
|
|
if err := finalizeWorkspaceSubscriptionSuccess(tx, &workspaceSubscription, &wsTransaction, &payment); err != nil {
|
|
return err
|
|
}
|
|
notifyEventData.PayStatus = types.SubscriptionPayStatusFailedAndUseBalance
|
|
workspaceSubscription.PayMethod = types.PaymentMethodErrAndUseBalance
|
|
logrus.Infof("Renewal succeeded with balance for %s/%s", workspace, regionDomain)
|
|
} else {
|
|
// Both payments failed
|
|
workspaceSubscription.PayStatus = types.SubscriptionPayStatusCanceled
|
|
workspaceSubscription.Status = types.SubscriptionStatusDebt
|
|
// Mark workspace as debt (e.g., add label )
|
|
if err := updateDebtNamespaceStatus(context.Background(), dao.K8sManager.GetClient(), SuspendDebtNamespaceAnnoStatus, []string{workspace}); err != nil {
|
|
return fmt.Errorf("update namespace status error: %w", err)
|
|
}
|
|
notifyEventData.PayStatus = types.SubscriptionPayStatusFailed
|
|
logrus.Warnf("Renewal failed for %s/%s, set to debt status", workspace, regionDomain)
|
|
}
|
|
|
|
if _, err = dao.UserNotificationService.HandleWorkspaceSubscriptionEvent(context.Background(), userUID, notifyEventData, types.SubscriptionTransactionTypeRenewed, []usernotify.NotificationMethod{usernotify.NotificationMethodEmail}); err != nil {
|
|
logrus.Errorf(
|
|
"failed to send subscription failure notification to user %s: %v",
|
|
userUID,
|
|
err,
|
|
)
|
|
// return fmt.Errorf("failed to send subscription success notification to user %s: %w", userUID, err)
|
|
}
|
|
|
|
if err := tx.Save(workspaceSubscription).Error; err != nil {
|
|
return fmt.Errorf("failed to update subscription to debt status: %w", err)
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// checkout.session.expired
|
|
// handleWorkspaceSubscriptionSessionExpired handles payment session expirations
|
|
func handleWorkspaceSubscriptionSessionExpired(event *stripe.Event) error {
|
|
// Parse session data from webhook event
|
|
sessionData, err := services.StripeServiceInstance.ParseWebhookEventData(event)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to parse webhook data: %w", err)
|
|
}
|
|
|
|
session, ok := sessionData.(*stripe.CheckoutSession)
|
|
if !ok {
|
|
return errors.New("invalid session data type")
|
|
}
|
|
|
|
isLocal, err := checkIsLocalEvent(session)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to check event locality: %w", err)
|
|
}
|
|
if !isLocal {
|
|
return nil
|
|
}
|
|
|
|
logrus.Infof("Processing workspace subscription session expired: %s", session.ID)
|
|
|
|
// Get metadata
|
|
paymentID := session.Metadata["payment_id"]
|
|
if paymentID == "" {
|
|
return errors.New("missing payment_id in session metadata")
|
|
}
|
|
|
|
// Database operations within transaction
|
|
return dao.DBClient.GlobalTransactionHandler(func(tx *gorm.DB) error {
|
|
// 检查 PaymentOrder 是否存在
|
|
var paymentOrder types.PaymentOrder
|
|
if err := tx.Where("id = ?", paymentID).First(&paymentOrder).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return fmt.Errorf("payment order not found for ID: %s", paymentID)
|
|
}
|
|
return fmt.Errorf("failed to query payment order: %w", err)
|
|
}
|
|
|
|
// 更新 PaymentOrder 状态为 Expired
|
|
if err := tx.Model(&types.PaymentOrder{}).Where("id = ?", paymentID).Updates(map[string]any{
|
|
"status": types.PaymentStatusExpired,
|
|
}).Error; err != nil {
|
|
return fmt.Errorf("failed to update payment order status: %w", err)
|
|
}
|
|
|
|
// 检查 WorkspaceSubscriptionTransaction 是否存在
|
|
var subscriptionTx types.WorkspaceSubscriptionTransaction
|
|
if err := tx.Where("pay_id = ?", paymentID).First(&subscriptionTx).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("failed to query workspace subscription transaction: %w", err)
|
|
}
|
|
|
|
// 更新 WorkspaceSubscriptionTransaction 状态为 Failed
|
|
if err := tx.Model(&types.WorkspaceSubscriptionTransaction{}).Where("pay_id = ?", paymentID).Updates(map[string]any{
|
|
"pay_status": types.SubscriptionPayStatusExpired,
|
|
"status": types.SubscriptionTransactionStatusFailed,
|
|
"status_desc": fmt.Sprintf("Payment session expired: %s", event.Type),
|
|
}).Error; err != nil {
|
|
return fmt.Errorf("failed to update workspace subscription transaction: %w", err)
|
|
}
|
|
|
|
// 记录日志
|
|
logrus.Infof(
|
|
"Successfully marked workspace subscription transaction as failed for payment ID: %s, backend controller will handle subscription updates",
|
|
paymentID,
|
|
)
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func CreateWorkspaceSubscriptionPortalSession(c *gin.Context) {
|
|
req, err := helper.ParseWorkspaceSubscriptionInfoReq(c)
|
|
if err != nil {
|
|
SetErrorResp(
|
|
c,
|
|
http.StatusBadRequest,
|
|
gin.H{"error": fmt.Sprintf("failed to parse request: %v", err)},
|
|
)
|
|
return
|
|
}
|
|
if err := authenticateRequest(c, req); err != nil {
|
|
SetErrorResp(
|
|
c,
|
|
http.StatusUnauthorized,
|
|
gin.H{"error": fmt.Sprintf("authenticate error : %v", err)},
|
|
)
|
|
return
|
|
}
|
|
|
|
if services.StripeServiceInstance == nil {
|
|
SetErrorResp(
|
|
c,
|
|
http.StatusInternalServerError,
|
|
gin.H{"error": "Stripe service not configured"},
|
|
)
|
|
return
|
|
}
|
|
|
|
// Get or create customer
|
|
customer, err := services.StripeServiceInstance.GetCustomer(req.UserUID.String(), "")
|
|
if err != nil {
|
|
SetErrorResp(
|
|
c,
|
|
http.StatusInternalServerError,
|
|
gin.H{"error": fmt.Sprintf("failed to get/create customer: %v", err)},
|
|
)
|
|
return
|
|
}
|
|
|
|
// Create portal session
|
|
portalSession, err := services.StripeServiceInstance.CreatePortalSession(customer.ID)
|
|
if err != nil {
|
|
SetErrorResp(
|
|
c,
|
|
http.StatusInternalServerError,
|
|
gin.H{"error": fmt.Sprintf("failed to create portal session: %v", err)},
|
|
)
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"url": portalSession.URL,
|
|
"success": true,
|
|
})
|
|
}
|
|
|
|
// ProcessExpiredWorkspaceSubscriptions processes all expired workspace subscriptions
|
|
// @Summary Process expired workspace subscriptions
|
|
// @Description Process all expired workspace subscriptions in the current region
|
|
// @Tags WorkspaceSubscription
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param req body helper.AuthBase true "AuthBase"
|
|
// @Success 200 {object} gin.H
|
|
// @Router /payment/v1alpha1/workspace-subscription/process-expired [post]
|
|
func AdminProcessExpiredWorkspaceSubscriptions(c *gin.Context) {
|
|
if err := authenticateAdminRequest(c); err != nil {
|
|
SetErrorResp(
|
|
c,
|
|
http.StatusForbidden,
|
|
gin.H{"error": fmt.Sprintf("admin authenticate error: %v", err)},
|
|
)
|
|
return
|
|
}
|
|
|
|
logrus.Info("Starting expired workspace subscription processing...")
|
|
|
|
// err := dao.DBClient.ProcessExpiredWorkspaceSubscriptions()
|
|
// if err != nil {
|
|
// logrus.Errorf("Failed to process expired workspace subscriptions: %v", err)
|
|
// SetErrorResp(c, http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to process expired subscriptions: %v", err)})
|
|
// return
|
|
//}
|
|
|
|
logrus.Info("Completed expired workspace subscription processing")
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"success": true,
|
|
"message": "Expired workspace subscriptions processed successfully",
|
|
})
|
|
}
|
|
|
|
// AdminAddWorkspaceSubscription
|
|
// @Summary Admin add workspace subscription
|
|
// @Description Admin interface to add workspace subscription with no_need payment status. Performs quota check by default, can be skipped with skipQuotaCheck parameter
|
|
// @Tags WorkspaceSubscription
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param req body AdminWorkspaceSubscriptionAddReq true "AdminWorkspaceSubscriptionAddReq"
|
|
// @Success 200 {object} gin.H
|
|
// @Router /admin/v1alpha1/workspace-subscription/add [post]
|
|
|
|
// authenticateAndParseAdminRequest handles authentication and request parsing for admin operations
|
|
func authenticateAndParseAdminRequest(
|
|
c *gin.Context,
|
|
) (*helper.AdminWorkspaceSubscriptionAddReq, error) {
|
|
// Authenticate admin request
|
|
if err := authenticateAdminRequest(c); err != nil {
|
|
SetErrorResp(
|
|
c,
|
|
http.StatusForbidden,
|
|
gin.H{"error": fmt.Sprintf("admin authenticate error: %v", err)},
|
|
)
|
|
return nil, err
|
|
}
|
|
|
|
// Parse request
|
|
req, err := helper.ParseAdminWorkspaceSubscriptionAddReq(c)
|
|
if err != nil {
|
|
SetErrorResp(
|
|
c,
|
|
http.StatusBadRequest,
|
|
gin.H{"error": fmt.Sprintf("failed to parse request: %v", err)},
|
|
)
|
|
return nil, err
|
|
}
|
|
|
|
return req, nil
|
|
}
|
|
|
|
// setDefaultValues sets default values for optional fields in the request
|
|
func setDefaultValues(c *gin.Context, req *helper.AdminWorkspaceSubscriptionAddReq) error {
|
|
if req.UserUID == uuid.Nil {
|
|
// Get workspace owner as default user
|
|
workspaceOwner, err := dao.DBClient.GetWorkspaceUserUID(req.Workspace)
|
|
if err != nil {
|
|
SetErrorResp(
|
|
c,
|
|
http.StatusBadRequest,
|
|
gin.H{"error": fmt.Sprintf("failed to get workspace owner: %v", err)},
|
|
)
|
|
return err
|
|
}
|
|
req.UserUID = workspaceOwner
|
|
}
|
|
|
|
if req.RegionDomain == "" {
|
|
// Use current region as default
|
|
req.RegionDomain = dao.DBClient.GetLocalRegion().Domain
|
|
}
|
|
|
|
logrus.Infof(
|
|
"Admin adding workspace subscription: workspace=%s, region=%s, user=%s, plan=%s, operator=%s",
|
|
req.Workspace,
|
|
req.RegionDomain,
|
|
req.UserUID,
|
|
req.PlanName,
|
|
req.Operator,
|
|
)
|
|
|
|
return nil
|
|
}
|
|
|
|
// validatePlanAndPrice validates the subscription plan and finds the appropriate price
|
|
func validatePlanAndPrice(
|
|
c *gin.Context, req *helper.AdminWorkspaceSubscriptionAddReq,
|
|
) (*types.WorkspaceSubscriptionPlan, *types.ProductPrice, error) {
|
|
// Get plan details
|
|
plan, err := dao.DBClient.GetWorkspaceSubscriptionPlan(req.PlanName)
|
|
if err != nil {
|
|
SetErrorResp(
|
|
c,
|
|
http.StatusInternalServerError,
|
|
gin.H{"error": fmt.Sprintf("failed to get subscription plan: %v", err)},
|
|
)
|
|
return nil, nil, err
|
|
}
|
|
|
|
// Get plan price - for admin operations, we can be more flexible
|
|
var price *types.ProductPrice
|
|
for _, p := range plan.Prices {
|
|
if string(p.BillingCycle) == string(req.Period) {
|
|
price = &p
|
|
break
|
|
}
|
|
}
|
|
|
|
// For admin operations, if no exact price is found for the period,
|
|
// we can either use a default price or create a zero-price entry
|
|
// since admin operations don't require actual payment processing
|
|
if price == nil {
|
|
// Log a warning instead of returning an error for admin operations
|
|
logrus.Warnf(
|
|
"No price found for plan %s with period %s, using zero price for admin operation",
|
|
req.PlanName,
|
|
req.Period,
|
|
)
|
|
|
|
// Create a zero-price entry for admin operations
|
|
price = &types.ProductPrice{
|
|
ProductID: plan.ID, // Set the product ID from the plan
|
|
BillingCycle: req.Period,
|
|
Price: 0, // Admin operations don't need payment
|
|
OriginalPrice: 0,
|
|
}
|
|
}
|
|
|
|
return plan, price, nil
|
|
}
|
|
|
|
// validateExistingSubscription checks existing subscription and determines operator
|
|
func validateExistingSubscription(
|
|
c *gin.Context, req *helper.AdminWorkspaceSubscriptionAddReq,
|
|
) (*types.WorkspaceSubscription, error) {
|
|
// Check existing subscription and auto-determine operator if needed
|
|
existingSubscription, err := dao.DBClient.GetWorkspaceSubscription(
|
|
req.Workspace,
|
|
req.RegionDomain,
|
|
)
|
|
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
SetErrorResp(
|
|
c,
|
|
http.StatusInternalServerError,
|
|
gin.H{"error": fmt.Sprintf("failed to check existing workspace subscription: %v", err)},
|
|
)
|
|
return nil, err
|
|
}
|
|
|
|
// Auto-determine operator if not provided
|
|
if req.Operator == "" {
|
|
if existingSubscription != nil &&
|
|
existingSubscription.Status != types.SubscriptionStatusDeleted {
|
|
// Check if it's the same plan (renewal) or different plan (upgrade)
|
|
if existingSubscription.PlanName == req.PlanName {
|
|
// Same plan - this is a renewal
|
|
req.Operator = types.SubscriptionTransactionTypeRenewed
|
|
} else {
|
|
// Different plan - this is an upgrade
|
|
req.Operator = types.SubscriptionTransactionTypeUpgraded
|
|
}
|
|
} else {
|
|
// No existing subscription or deleted, default to create
|
|
req.Operator = types.SubscriptionTransactionTypeCreated
|
|
}
|
|
}
|
|
|
|
// For creation operation, ensure no existing subscription
|
|
if req.Operator == types.SubscriptionTransactionTypeCreated && existingSubscription != nil &&
|
|
existingSubscription.Status != types.SubscriptionStatusDeleted {
|
|
SetErrorResp(
|
|
c,
|
|
http.StatusConflict,
|
|
gin.H{"error": "workspace already has an active subscription"},
|
|
)
|
|
return nil, errors.New("workspace already has an active subscription")
|
|
}
|
|
|
|
// For upgrade/renew operations, ensure subscription exists
|
|
if (req.Operator == types.SubscriptionTransactionTypeUpgraded || req.Operator == types.SubscriptionTransactionTypeRenewed) &&
|
|
(existingSubscription == nil || existingSubscription.Status == types.SubscriptionStatusDeleted) {
|
|
SetErrorResp(
|
|
c,
|
|
http.StatusBadRequest,
|
|
gin.H{"error": "workspace subscription not found for upgrade/renew operation"},
|
|
)
|
|
return nil, errors.New("workspace subscription not found for upgrade/renew operation")
|
|
}
|
|
|
|
return existingSubscription, nil
|
|
}
|
|
|
|
// checkSubscriptionQuota performs quota checking for the subscription
|
|
func checkSubscriptionQuota(c *gin.Context, req *helper.AdminWorkspaceSubscriptionAddReq) error {
|
|
// Perform quota check by default (unless explicitly skipped)
|
|
if !req.SkipQuotaCheck {
|
|
ok, err := CheckQuota(context.Background(), req.Workspace, req.PlanName)
|
|
if err != nil {
|
|
SetErrorResp(
|
|
c,
|
|
http.StatusInternalServerError,
|
|
gin.H{"error": fmt.Sprintf("failed to check quota: %v", err)},
|
|
)
|
|
return err
|
|
}
|
|
if !ok {
|
|
SetErrorResp(
|
|
c,
|
|
http.StatusConflict,
|
|
gin.H{
|
|
"error": "quota exceeded for the requested plan, please change the resource usage to within the expected workspace subscription quota",
|
|
"code": 10004,
|
|
},
|
|
)
|
|
return errors.New("quota exceeded")
|
|
}
|
|
} else {
|
|
logrus.Infof("Admin skipped quota check for workspace %s, plan %s", req.Workspace, req.PlanName)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// createSubscriptionTransaction creates the workspace subscription transaction record
|
|
//
|
|
//nolint:unparam // Function returns error for interface compatibility, always nil
|
|
func createSubscriptionTransaction(
|
|
req *helper.AdminWorkspaceSubscriptionAddReq,
|
|
price *types.ProductPrice,
|
|
existingSubscription *types.WorkspaceSubscription,
|
|
) (*types.WorkspaceSubscriptionTransaction, error) {
|
|
now := time.Now().UTC()
|
|
|
|
transaction := &types.WorkspaceSubscriptionTransaction{
|
|
ID: uuid.New(),
|
|
From: types.TransactionFromSystem, // Mark as system/admin initiated
|
|
Workspace: req.Workspace,
|
|
RegionDomain: req.RegionDomain,
|
|
UserUID: req.UserUID,
|
|
NewPlanName: req.PlanName,
|
|
Operator: req.Operator,
|
|
StartAt: now,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
Status: types.SubscriptionTransactionStatusCompleted,
|
|
PayStatus: types.SubscriptionPayStatusNoNeed, // Skip payment processing
|
|
Period: req.Period,
|
|
Amount: price.Price,
|
|
StatusDesc: "Admin added subscription: " + req.Description,
|
|
}
|
|
|
|
// Set old plan info if upgrading or renewing
|
|
if existingSubscription != nil {
|
|
transaction.OldPlanName = existingSubscription.PlanName
|
|
transaction.OldPlanStatus = existingSubscription.Status
|
|
} else {
|
|
// For new subscriptions, set OldPlanStatus to NORMAL when OldPlanName is empty
|
|
transaction.OldPlanStatus = types.SubscriptionStatusNormal
|
|
}
|
|
|
|
return transaction, nil
|
|
}
|
|
|
|
// createOrUpdateWorkspaceSubscription creates a new subscription or updates an existing one
|
|
//
|
|
//nolint:unparam // Function returns error for interface compatibility, always nil
|
|
func createOrUpdateWorkspaceSubscription(
|
|
req *helper.AdminWorkspaceSubscriptionAddReq, existingSubscription *types.WorkspaceSubscription,
|
|
) (*types.WorkspaceSubscription, error) {
|
|
now := time.Now().UTC()
|
|
|
|
var workspaceSubscription *types.WorkspaceSubscription
|
|
if existingSubscription != nil {
|
|
workspaceSubscription = existingSubscription
|
|
workspaceSubscription.PayStatus = types.SubscriptionPayStatusNoNeed
|
|
workspaceSubscription.TrafficStatus = types.WorkspaceTrafficStatusActive
|
|
workspaceSubscription.Status = types.SubscriptionStatusNormal
|
|
workspaceSubscription.PayMethod = types.PaymentMethodBalance // Admin operations use balance payment
|
|
|
|
// Parse period duration
|
|
periodDuration, err := types.ParsePeriod(req.Period)
|
|
if err != nil {
|
|
// Fallback to monthly if parsing fails
|
|
periodDuration = 30 * 24 * time.Hour
|
|
}
|
|
|
|
// Handle different operators
|
|
switch req.Operator {
|
|
case types.SubscriptionTransactionTypeRenewed:
|
|
// For renewal: only extend ExpireAt, don't modify current period
|
|
// The processor will handle period renewal when CurrentPeriodEndAt approaches
|
|
if workspaceSubscription.ExpireAt == nil {
|
|
expireTime := existingSubscription.CurrentPeriodEndAt.Add(periodDuration)
|
|
workspaceSubscription.ExpireAt = &expireTime
|
|
} else {
|
|
expireTime := workspaceSubscription.ExpireAt.Add(periodDuration)
|
|
workspaceSubscription.ExpireAt = &expireTime
|
|
}
|
|
logrus.Infof(
|
|
"Renewal: Extended ExpireAt to %s, current period unchanged (ends at %s)",
|
|
workspaceSubscription.ExpireAt.Format(time.RFC3339),
|
|
workspaceSubscription.CurrentPeriodEndAt.Format(time.RFC3339),
|
|
)
|
|
|
|
case types.SubscriptionTransactionTypeUpgraded, types.SubscriptionTransactionTypeDowngraded:
|
|
// For upgrade/downgrade: immediately update current period and plan
|
|
workspaceSubscription.PlanName = req.PlanName
|
|
workspaceSubscription.CurrentPeriodStartAt = now
|
|
workspaceSubscription.CurrentPeriodEndAt = now.Add(periodDuration)
|
|
|
|
// Set ExpireAt to the new current period end if not set, or extend it
|
|
if workspaceSubscription.ExpireAt == nil ||
|
|
workspaceSubscription.ExpireAt.Before(workspaceSubscription.CurrentPeriodEndAt) {
|
|
workspaceSubscription.ExpireAt = &workspaceSubscription.CurrentPeriodEndAt
|
|
}
|
|
logrus.Infof(
|
|
"Upgrade/Downgrade: Updated current period to %s - %s, plan=%s",
|
|
workspaceSubscription.CurrentPeriodStartAt.Format(time.RFC3339),
|
|
workspaceSubscription.CurrentPeriodEndAt.Format(time.RFC3339),
|
|
req.PlanName,
|
|
)
|
|
}
|
|
} else {
|
|
// Create new subscription - parse period for correct end time
|
|
periodDuration, err := types.ParsePeriod(req.Period)
|
|
var endTime time.Time
|
|
if err != nil {
|
|
// Fallback to monthly if parsing fails
|
|
endTime = now.AddDate(0, 1, 0)
|
|
} else {
|
|
endTime = now.Add(periodDuration)
|
|
}
|
|
|
|
workspaceSubscription = &types.WorkspaceSubscription{
|
|
ID: uuid.New(),
|
|
PlanName: req.PlanName,
|
|
Workspace: req.Workspace,
|
|
RegionDomain: req.RegionDomain,
|
|
UserUID: req.UserUID,
|
|
Status: types.SubscriptionStatusNormal,
|
|
TrafficStatus: types.WorkspaceTrafficStatusActive,
|
|
PayStatus: types.SubscriptionPayStatusNoNeed,
|
|
PayMethod: types.PaymentMethodBalance, // Internal admin method
|
|
CurrentPeriodStartAt: now,
|
|
CurrentPeriodEndAt: endTime,
|
|
CreateAt: now,
|
|
ExpireAt: stripe.Time(endTime),
|
|
}
|
|
}
|
|
|
|
return workspaceSubscription, nil
|
|
}
|
|
|
|
// addTrafficAndAIPackages handles adding traffic and AI quota packages to the subscription
|
|
func addTrafficAndAIPackages(
|
|
tx *gorm.DB,
|
|
req *helper.AdminWorkspaceSubscriptionAddReq,
|
|
plan *types.WorkspaceSubscriptionPlan,
|
|
existingSubscription, workspaceSubscription *types.WorkspaceSubscription,
|
|
transactionID string,
|
|
) error {
|
|
// For renewal operations, skip adding packages - they will be handled by the processor
|
|
// when the current period is about to end
|
|
if req.Operator == types.SubscriptionTransactionTypeRenewed {
|
|
logrus.Infof(
|
|
"Skipping traffic/AI package addition for renewal: will be handled by processor before period end",
|
|
)
|
|
return nil
|
|
}
|
|
|
|
// For upgrade/downgrade/create operations, add packages immediately
|
|
// Add traffic package
|
|
if plan.Traffic > 0 && req.Operator != types.SubscriptionTransactionTypeDowngraded {
|
|
// Calculate additional traffic for upgrades
|
|
additionalTraffic := plan.Traffic
|
|
if req.Operator == types.SubscriptionTransactionTypeUpgraded &&
|
|
existingSubscription != nil &&
|
|
existingSubscription.PlanName != types.FreeSubscriptionPlanName {
|
|
oldPlan, err := dao.DBClient.GetWorkspaceSubscriptionPlan(
|
|
existingSubscription.PlanName,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get old workspace subscription plan: %w", err)
|
|
}
|
|
additionalTraffic -= oldPlan.Traffic
|
|
if additionalTraffic < 0 {
|
|
additionalTraffic = 0
|
|
}
|
|
}
|
|
|
|
if additionalTraffic > 0 {
|
|
err := helper.AddTrafficPackage(
|
|
tx,
|
|
dao.K8sManager.GetClient(),
|
|
workspaceSubscription,
|
|
plan,
|
|
workspaceSubscription.CurrentPeriodEndAt,
|
|
types.WorkspaceTrafficFromWorkspaceSubscription,
|
|
transactionID,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to add traffic package: %w", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Add AI quota package
|
|
if plan.AIQuota > 0 && req.Operator != types.SubscriptionTransactionTypeDowngraded {
|
|
// Calculate additional AI quota for upgrades
|
|
additionalAIQuota := plan.AIQuota
|
|
if req.Operator == types.SubscriptionTransactionTypeUpgraded &&
|
|
existingSubscription != nil &&
|
|
existingSubscription.PlanName != types.FreeSubscriptionPlanName {
|
|
oldPlan, err := dao.DBClient.GetWorkspaceSubscriptionPlan(
|
|
existingSubscription.PlanName,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get old workspace subscription plan: %w", err)
|
|
}
|
|
additionalAIQuota -= oldPlan.AIQuota
|
|
if additionalAIQuota < 0 {
|
|
additionalAIQuota = 0
|
|
}
|
|
}
|
|
|
|
if additionalAIQuota > 0 {
|
|
err := cockroach.AddWorkspaceSubscriptionAIQuotaPackage(
|
|
tx,
|
|
workspaceSubscription.ID,
|
|
additionalAIQuota,
|
|
workspaceSubscription.CurrentPeriodEndAt,
|
|
types.PKGFromWorkspaceSubscription,
|
|
transactionID,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to add AI quota package: %w", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// processSubscriptionTransaction handles the complete database transaction for subscription creation/update
|
|
func processSubscriptionTransaction(
|
|
req *helper.AdminWorkspaceSubscriptionAddReq,
|
|
plan *types.WorkspaceSubscriptionPlan,
|
|
price *types.ProductPrice,
|
|
existingSubscription *types.WorkspaceSubscription,
|
|
) error {
|
|
return dao.DBClient.GlobalTransactionHandler(func(tx *gorm.DB) error {
|
|
// Create transaction record
|
|
transaction, err := createSubscriptionTransaction(req, price, existingSubscription)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create subscription transaction: %w", err)
|
|
}
|
|
|
|
if err := dao.DBClient.CreateWorkspaceSubscriptionTransaction(tx, transaction); err != nil {
|
|
return fmt.Errorf("failed to create workspace subscription transaction: %w", err)
|
|
}
|
|
|
|
// Create or update workspace subscription
|
|
workspaceSubscription, err := createOrUpdateWorkspaceSubscription(req, existingSubscription)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create or update workspace subscription: %w", err)
|
|
}
|
|
|
|
// Save workspace subscription
|
|
if err := tx.Save(workspaceSubscription).Error; err != nil {
|
|
return fmt.Errorf("failed to save workspace subscription: %w", err)
|
|
}
|
|
|
|
// Update resource quota for creation, upgrade or downgrade (not for renewal)
|
|
// Renewal doesn't change the current period plan, so no quota update needed
|
|
if req.Operator != types.SubscriptionTransactionTypeRenewed {
|
|
if err := updateWorkspaceSubscriptionQuota(req.PlanName, workspaceSubscription.Workspace); err != nil {
|
|
return fmt.Errorf("failed to update workspace subscription quota: %w", err)
|
|
}
|
|
}
|
|
|
|
// Add traffic and AI packages
|
|
// For renewal, this will be skipped and handled by the processor
|
|
if err := addTrafficAndAIPackages(tx, req, plan, existingSubscription, workspaceSubscription, transaction.ID.String()); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Update workspace subscription namespace annotation for subscription
|
|
if err := updateWorkspaceSubscriptionNamespaceStatus(req.Workspace); err != nil {
|
|
return fmt.Errorf(
|
|
"failed to update workspace subscription namespace annotation after admin add: %w",
|
|
err,
|
|
)
|
|
}
|
|
|
|
logrus.Infof(
|
|
"Successfully added workspace subscription via admin interface: workspace=%s, plan=%s, operator=%s, transaction_id=%s",
|
|
req.Workspace,
|
|
req.PlanName,
|
|
req.Operator,
|
|
transaction.ID,
|
|
)
|
|
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// GetWorkspaceSubscriptionPlans
|
|
// @Summary Get workspace subscription plans by namespaces
|
|
// @Description Get subscription plan names for multiple namespaces, returning "PAYG" for non-subscribed workspaces
|
|
// @Tags WorkspaceSubscription
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param req body helper.WorkspaceSubscriptionPlansReq true "WorkspaceSubscriptionPlansReq"
|
|
// @Success 200 {object} WorkspaceSubscriptionPlansResp
|
|
// @Router /account/v1alpha1/workspace-subscription/plans [post]
|
|
func GetWorkspaceSubscriptionPlans(c *gin.Context) {
|
|
req, err := helper.ParseWorkspaceSubscriptionPlansReq(c)
|
|
if err != nil {
|
|
c.JSON(
|
|
http.StatusBadRequest,
|
|
helper.ErrorMessage{Error: fmt.Sprintf("failed to parse request: %v", err)},
|
|
)
|
|
return
|
|
}
|
|
|
|
// Authenticate request
|
|
if err := authenticateRequest(c, req); err != nil {
|
|
c.JSON(
|
|
http.StatusUnauthorized,
|
|
helper.ErrorMessage{Error: fmt.Sprintf("authenticate error: %v", err)},
|
|
)
|
|
return
|
|
}
|
|
|
|
// Response structure
|
|
type NamespacePlanInfo struct {
|
|
Namespace string `json:"namespace"`
|
|
PlanName string `json:"planName"`
|
|
}
|
|
|
|
type WorkspaceSubscriptionPlansResp struct {
|
|
Plans []NamespacePlanInfo `json:"plans"`
|
|
}
|
|
|
|
// Get local region domain
|
|
regionDomain := dao.DBClient.GetLocalRegion().Domain
|
|
|
|
plans := make([]NamespacePlanInfo, 0, len(req.Namespaces))
|
|
|
|
// Query subscription for each namespace
|
|
for _, namespace := range req.Namespaces {
|
|
subscription, err := dao.DBClient.GetWorkspaceSubscription(namespace, regionDomain)
|
|
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
dao.Logger.Errorf(
|
|
"Failed to get workspace subscription for namespace %s: %v",
|
|
namespace,
|
|
err,
|
|
)
|
|
// Continue processing other namespaces, return PAYG for this one
|
|
plans = append(plans, NamespacePlanInfo{
|
|
Namespace: namespace,
|
|
PlanName: "PAYG",
|
|
})
|
|
continue
|
|
}
|
|
|
|
planName := "PAYG"
|
|
if subscription != nil {
|
|
planName = subscription.PlanName
|
|
}
|
|
|
|
plans = append(plans, NamespacePlanInfo{
|
|
Namespace: namespace,
|
|
PlanName: planName,
|
|
})
|
|
}
|
|
|
|
c.JSON(http.StatusOK, WorkspaceSubscriptionPlansResp{
|
|
Plans: plans,
|
|
})
|
|
}
|