Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 56 additions & 20 deletions service/account/api/credits.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"fmt"
"net/http"
"sync"
"time"

"github.com/gin-gonic/gin"
"github.com/google/uuid"
Expand All @@ -20,7 +21,7 @@ import (
// @Produce json
// @Param req body CreditsInfoReq true "CreditsInfoReq"
// @Success 200 {object} CreditsInfoResp
// @Router /account/v1alpha1/credits/info [post]
// @Router /payment/v1alpha1/credits/info [post]
func GetCreditsInfo(c *gin.Context) {
req := &helper.AuthBase{}
if err := authenticateRequest(c, req); err != nil {
Expand Down Expand Up @@ -55,6 +56,20 @@ type CreditsInfoReq struct {
KYCDeductionCreditsBalance int64 `json:"kycDeductionCreditsBalance"`
CurrentPlanCreditsBalance int64 `json:"currentPlanCreditsBalance"`
CurrentPlanCreditsDeductionBalance int64 `json:"currentPlanCreditsDeductionBalance"`

// CreditsList holds the same rows the aggregate fields above are summed
// from, so sum(amount) == Credits and sum(usedAmount) == DeductionCredits;
// exhausted rows stay in the list to keep that invariant. Callers filter
// for display (e.g. amount > usedAmount).
CreditsList []CreditsItem `json:"creditsList"`
}

type CreditsItem struct {
Amount int64 `json:"amount"`
UsedAmount int64 `json:"usedAmount"`
StartAt *time.Time `json:"startAt"`
ExpireAt *time.Time `json:"expireAt"`
Status types.CreditsStatus `json:"status"`
}

func getCreditsInfo(userUID uuid.UUID) (any, error) {
Expand Down Expand Up @@ -112,34 +127,55 @@ func getCreditsInfo(userUID uuid.UUID) (any, error) {
return nil, fmt.Errorf("failed to get available credits: %w", err)
}

var currentCredits, freeCredits types.Credits
creditsInfo = buildCreditsInfo(
credits,
currentPlan.ID.String(),
freePlan.ID.String(),
subscription.PlanName,
)
creditsInfo.UserUID = userUID
creditsInfo.Balance = account.Balance
creditsInfo.DeductionBalance = account.DeductionBalance
return creditsInfo, nil
}

func buildCreditsInfo(
credits []types.Credits,
currentPlanID, freePlanID, planName string,
) CreditsInfoReq {
creditsInfo := CreditsInfoReq{CreditsList: make([]CreditsItem, 0, len(credits))}
for i := range credits {
switch credits[i].FromID {
case currentPlan.ID.String():
currentCredits = credits[i]
creditsInfo.CurrentPlanCreditsBalance = currentCredits.Amount
creditsInfo.CurrentPlanCreditsDeductionBalance = currentCredits.UsedAmount
case freePlan.ID.String():
freeCredits = credits[i]
creditsInfo.KYCDeductionCreditsBalance = freeCredits.Amount
creditsInfo.KYCDeductionCreditsDeductionBalance = freeCredits.UsedAmount
case currentPlanID:
creditsInfo.CurrentPlanCreditsBalance = credits[i].Amount
creditsInfo.CurrentPlanCreditsDeductionBalance = credits[i].UsedAmount
case freePlanID:
creditsInfo.KYCDeductionCreditsBalance = credits[i].Amount
creditsInfo.KYCDeductionCreditsDeductionBalance = credits[i].UsedAmount
}
}
if subscription.PlanName == types.FreeSubscriptionPlanName {
if planName == types.FreeSubscriptionPlanName {
creditsInfo.KYCDeductionCreditsBalance = creditsInfo.CurrentPlanCreditsBalance
creditsInfo.KYCDeductionCreditsDeductionBalance = creditsInfo.CurrentPlanCreditsDeductionBalance
}

var totalCredits, totalDeductionCredits int64
for _, c := range credits {
totalCredits += c.Amount
totalDeductionCredits += c.UsedAmount
creditsInfo.Credits += c.Amount
creditsInfo.DeductionCredits += c.UsedAmount
creditsInfo.CreditsList = append(creditsInfo.CreditsList, CreditsItem{
Amount: c.Amount,
UsedAmount: c.UsedAmount,
StartAt: nonZeroTime(c.StartAt),
ExpireAt: nonZeroTime(c.ExpireAt),
Status: c.Status,
})
}
return creditsInfo
}

creditsInfo.UserUID = userUID
creditsInfo.Balance = account.Balance
creditsInfo.DeductionBalance = account.DeductionBalance
creditsInfo.Credits = totalCredits
creditsInfo.DeductionCredits = totalDeductionCredits
return creditsInfo, nil
func nonZeroTime(t time.Time) *time.Time {
if t.IsZero() {
return nil
}
return &t
}
104 changes: 104 additions & 0 deletions service/account/api/credits_test.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
package api

import (
"encoding/json"
"strings"
"testing"
"time"

"github.com/google/uuid"
"github.com/labring/sealos/controllers/pkg/types"
"github.com/labring/sealos/service/account/dao"
)

Expand All @@ -27,3 +30,104 @@ func Test_getCreditsInfo(t *testing.T) {

t.Logf("getCreditsInfo() userCreditsInfo = %#+v, %s", userCreditsInfo, time.Since(start))
}

func Test_buildCreditsInfo(t *testing.T) {
freePlanID := uuid.New().String()
paidPlanID := uuid.New().String()
expireAt := time.Date(2026, 9, 20, 0, 0, 0, 0, time.UTC)
startAt := expireAt.AddDate(0, -1, 0)

rows := []types.Credits{
{
Amount: 500000,
UsedAmount: 280000,
FromID: freePlanID,
StartAt: startAt,
ExpireAt: expireAt,
Status: types.CreditsStatusActive,
},
{
Amount: 220000,
UsedAmount: 220000,
FromID: paidPlanID,
Status: types.CreditsStatusUsedUp,
},
}

info := buildCreditsInfo(rows, paidPlanID, freePlanID, "pro")

if info.Credits != 720000 || info.DeductionCredits != 500000 {
t.Fatalf("totals = %d/%d, want 720000/500000", info.Credits, info.DeductionCredits)
}
if info.KYCDeductionCreditsBalance != 500000 ||
info.KYCDeductionCreditsDeductionBalance != 280000 {
t.Fatalf(
"kyc pair = %d/%d, want 500000/280000",
info.KYCDeductionCreditsBalance,
info.KYCDeductionCreditsDeductionBalance,
)
}

// The list must cover exactly the aggregated rows, exhausted ones included.
if len(info.CreditsList) != 2 {
t.Fatalf("len(CreditsList) = %d, want 2", len(info.CreditsList))
}
var listAmount, listUsed int64
for _, item := range info.CreditsList {
listAmount += item.Amount
listUsed += item.UsedAmount
}
if listAmount != info.Credits || listUsed != info.DeductionCredits {
t.Fatalf(
"list sums %d/%d do not match totals %d/%d",
listAmount, listUsed, info.Credits, info.DeductionCredits,
)
}

if info.CreditsList[0].ExpireAt == nil ||
!info.CreditsList[0].ExpireAt.Equal(expireAt) {
t.Fatalf("ExpireAt = %v, want %v", info.CreditsList[0].ExpireAt, expireAt)
}
if info.CreditsList[1].ExpireAt != nil || info.CreditsList[1].StartAt != nil {
t.Fatalf("zero times must serialize as nil, got %#v", info.CreditsList[1])
}
if info.CreditsList[1].Status != types.CreditsStatusUsedUp {
t.Fatalf("Status = %q, want %q", info.CreditsList[1].Status, types.CreditsStatusUsedUp)
}
}

func Test_buildCreditsInfo_FreePlanCopiesKYCPair(t *testing.T) {
freePlanID := uuid.New().String()
rows := []types.Credits{
{
Amount: 100000,
UsedAmount: 40000,
FromID: freePlanID,
Status: types.CreditsStatusActive,
},
}

// On the Free plan the current plan IS the free plan, so the row lands in
// the current-plan pair and must be copied to the KYC pair.
info := buildCreditsInfo(rows, freePlanID, freePlanID, types.FreeSubscriptionPlanName)

if info.KYCDeductionCreditsBalance != 100000 ||
info.KYCDeductionCreditsDeductionBalance != 40000 {
t.Fatalf(
"kyc pair = %d/%d, want 100000/40000",
info.KYCDeductionCreditsBalance,
info.KYCDeductionCreditsDeductionBalance,
)
}
}

func Test_buildCreditsInfo_EmptyListSerializesAsArray(t *testing.T) {
info := buildCreditsInfo(nil, uuid.New().String(), uuid.New().String(), "pro")
raw, err := json.Marshal(info)
if err != nil {
t.Fatalf("marshal: %v", err)
}
if !strings.Contains(string(raw), `"creditsList":[]`) {
t.Fatalf(`want "creditsList":[] in output, got %s`, raw)
}
}
Loading