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:
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user