fix(usage): subscription billing honours group rate multiplier

Subscription-mode billing was consuming quota at TotalCost (raw) instead of
ActualCost (TotalCost * RateMultiplier), so per-group rate multipliers —
including free subscriptions (multiplier = 0) — were silently ignored.
Switch the three subscription cost writes in buildUsageBillingCommand,
finalizePostUsageBilling, and the legacy postUsageBilling fallback to
ActualCost, and add a table-driven test covering 2x / 0.5x / free multipliers
plus a balance-mode regression check.
This commit is contained in:
erio
2026-04-17 17:00:45 +08:00
parent fd0c9a1305
commit 44cdef7934
2 changed files with 96 additions and 5 deletions

View File

@@ -7317,8 +7317,10 @@ func postUsageBilling(ctx context.Context, p *postUsageBillingParams, deps *bill
cost := p.Cost cost := p.Cost
if p.IsSubscriptionBill { if p.IsSubscriptionBill {
if cost.TotalCost > 0 { // Subscription usage tracked by ActualCost so group rate multiplier
if err := deps.userSubRepo.IncrementUsage(billingCtx, p.Subscription.ID, cost.TotalCost); err != nil { // consumes the quota at the expected speed.
if cost.ActualCost > 0 {
if err := deps.userSubRepo.IncrementUsage(billingCtx, p.Subscription.ID, cost.ActualCost); err != nil {
slog.Error("increment subscription usage failed", "subscription_id", p.Subscription.ID, "error", err) slog.Error("increment subscription usage failed", "subscription_id", p.Subscription.ID, "error", err)
} }
} }
@@ -7417,9 +7419,13 @@ func buildUsageBillingCommand(requestID string, usageLog *UsageLog, p *postUsage
} }
} }
// Record subscription / balance cost using ActualCost so the group (and any
// user-specific) rate multiplier consumes subscription quota at the expected
// speed. TotalCost remains the raw (pre-multiplier) value; downstream guards
// on "> 0" still correctly skip free subscriptions (RateMultiplier == 0).
if p.IsSubscriptionBill && p.Subscription != nil && p.Cost.TotalCost > 0 { if p.IsSubscriptionBill && p.Subscription != nil && p.Cost.TotalCost > 0 {
cmd.SubscriptionID = &p.Subscription.ID cmd.SubscriptionID = &p.Subscription.ID
cmd.SubscriptionCost = p.Cost.TotalCost cmd.SubscriptionCost = p.Cost.ActualCost
} else if p.Cost.ActualCost > 0 { } else if p.Cost.ActualCost > 0 {
cmd.BalanceCost = p.Cost.ActualCost cmd.BalanceCost = p.Cost.ActualCost
} }
@@ -7478,8 +7484,8 @@ func finalizePostUsageBilling(p *postUsageBillingParams, deps *billingDeps, resu
} }
if p.IsSubscriptionBill { if p.IsSubscriptionBill {
if p.Cost.TotalCost > 0 && p.User != nil && p.APIKey != nil && p.APIKey.GroupID != nil { if p.Cost.ActualCost > 0 && p.User != nil && p.APIKey != nil && p.APIKey.GroupID != nil {
deps.billingCacheService.QueueUpdateSubscriptionUsage(p.User.ID, *p.APIKey.GroupID, p.Cost.TotalCost) deps.billingCacheService.QueueUpdateSubscriptionUsage(p.User.ID, *p.APIKey.GroupID, p.Cost.ActualCost)
} }
} else if p.Cost.ActualCost > 0 && p.User != nil { } else if p.Cost.ActualCost > 0 && p.User != nil {
deps.billingCacheService.QueueDeductBalance(p.User.ID, p.Cost.ActualCost) deps.billingCacheService.QueueDeductBalance(p.User.ID, p.Cost.ActualCost)

View File

@@ -0,0 +1,85 @@
//go:build unit
package service
import (
"testing"
)
// TestBuildUsageBillingCommand_SubscriptionAppliesRateMultiplier locks in the fix
// that subscription-mode billing honours the group (and any user-specific) rate
// multiplier — i.e. cmd.SubscriptionCost tracks ActualCost (= TotalCost *
// RateMultiplier), not raw TotalCost.
func TestBuildUsageBillingCommand_SubscriptionAppliesRateMultiplier(t *testing.T) {
t.Parallel()
groupID := int64(7)
subID := int64(42)
tests := []struct {
name string
totalCost float64
actualCost float64
isSubscription bool
wantSub float64
wantBalance float64
}{
{
name: "subscription with 2x multiplier consumes 2x quota",
totalCost: 1.0,
actualCost: 2.0,
isSubscription: true,
wantSub: 2.0,
wantBalance: 0,
},
{
name: "subscription with 0.5x multiplier consumes 0.5x quota",
totalCost: 1.0,
actualCost: 0.5,
isSubscription: true,
wantSub: 0.5,
wantBalance: 0,
},
{
name: "free subscription (multiplier 0) consumes no quota",
totalCost: 1.0,
actualCost: 0,
isSubscription: true,
wantSub: 0,
wantBalance: 0,
},
{
name: "balance billing keeps using ActualCost (regression)",
totalCost: 1.0,
actualCost: 2.0,
isSubscription: false,
wantSub: 0,
wantBalance: 2.0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
p := &postUsageBillingParams{
Cost: &CostBreakdown{TotalCost: tt.totalCost, ActualCost: tt.actualCost},
User: &User{ID: 1},
APIKey: &APIKey{ID: 2, GroupID: &groupID},
Account: &Account{ID: 3},
Subscription: &UserSubscription{ID: subID},
IsSubscriptionBill: tt.isSubscription,
}
cmd := buildUsageBillingCommand("req-1", nil, p)
if cmd == nil {
t.Fatal("buildUsageBillingCommand returned nil")
}
if cmd.SubscriptionCost != tt.wantSub {
t.Errorf("SubscriptionCost = %v, want %v", cmd.SubscriptionCost, tt.wantSub)
}
if cmd.BalanceCost != tt.wantBalance {
t.Errorf("BalanceCost = %v, want %v", cmd.BalanceCost, tt.wantBalance)
}
})
}
}