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
|
||||
|
||||
if p.IsSubscriptionBill {
|
||||
if cost.TotalCost > 0 {
|
||||
if err := deps.userSubRepo.IncrementUsage(billingCtx, p.Subscription.ID, cost.TotalCost); err != nil {
|
||||
// Subscription usage tracked by ActualCost so group rate multiplier
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
cmd.SubscriptionID = &p.Subscription.ID
|
||||
cmd.SubscriptionCost = p.Cost.TotalCost
|
||||
cmd.SubscriptionCost = p.Cost.ActualCost
|
||||
} else if p.Cost.ActualCost > 0 {
|
||||
cmd.BalanceCost = p.Cost.ActualCost
|
||||
}
|
||||
@@ -7478,8 +7484,8 @@ func finalizePostUsageBilling(p *postUsageBillingParams, deps *billingDeps, resu
|
||||
}
|
||||
|
||||
if p.IsSubscriptionBill {
|
||||
if p.Cost.TotalCost > 0 && p.User != nil && p.APIKey != nil && p.APIKey.GroupID != nil {
|
||||
deps.billingCacheService.QueueUpdateSubscriptionUsage(p.User.ID, *p.APIKey.GroupID, p.Cost.TotalCost)
|
||||
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.ActualCost)
|
||||
}
|
||||
} else if p.Cost.ActualCost > 0 && p.User != nil {
|
||||
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