feat(*): 模型计费与渠道目录 - 支持视频 Token 报价结算和稳定模型展示
This commit is contained in:
1 parent
90add8c466
commit
1a79005d25
57 files changed
+2904
-341
No files matched your search
@@ -9,6 +9,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/kernel"
|
||||
"infinite-canvas/backend/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@@ -657,6 +658,29 @@ func billingUsage(db *gorm.DB, orderID string) (*BillingUsage, error) {
|
||||
return &BillingUsage{InputTokens: log.InputTokens, OutputTokens: log.OutputTokens, CachedTokens: log.CachedTokens}, nil
|
||||
}
|
||||
|
||||
const (
|
||||
billingUsageSourceProvider = "provider"
|
||||
billingUsageSourceVideoFormula = "video_formula"
|
||||
)
|
||||
|
||||
func tokenSettlementUsage(db *gorm.DB, order model.BillingOrder) (*BillingUsage, string, error) {
|
||||
usage, err := billingUsage(db, order.ID)
|
||||
if err != nil && !errors.Is(err, ErrBillingUsageUnavailable) {
|
||||
return nil, "", err
|
||||
}
|
||||
if order.Capability != "video" {
|
||||
return usage, billingUsageSourceProvider, err
|
||||
}
|
||||
if err == nil && usage.OutputTokens > 0 && usage.InputTokens >= 0 && usage.CachedTokens >= 0 {
|
||||
return &BillingUsage{OutputTokens: usage.OutputTokens}, billingUsageSourceProvider, nil
|
||||
}
|
||||
// 只有提交时明确记录的公式用量可以结算,预授权 Quantity 含余量,不能用作最终用量。
|
||||
if order.VideoFormulaTokens > 0 {
|
||||
return &BillingUsage{OutputTokens: order.VideoFormulaTokens}, billingUsageSourceVideoFormula, nil
|
||||
}
|
||||
return usage, billingUsageSourceProvider, err
|
||||
}
|
||||
|
||||
func (r *Repository) RecordBillingResolution(id string, actorUserID string, note string) error {
|
||||
return r.db.Model(&model.BillingOrder{}).Where("id = ?", id).Updates(map[string]any{
|
||||
"resolved_by": actorUserID, "resolution_note": note, "updated_at": time.Now(),
|
||||
@@ -705,6 +729,7 @@ func (r *Repository) MarkBillingUncertain(id string, errorText string) error {
|
||||
|
||||
func (r *Repository) SettleBillingOrder(id string, providerRequestID string) error {
|
||||
var observedUsage *BillingUsage
|
||||
var observedUsageSource string
|
||||
var observedActual int64
|
||||
observedActualAvailable := false
|
||||
err := r.db.Transaction(func(tx *gorm.DB) error {
|
||||
@@ -719,11 +744,12 @@ func (r *Repository) SettleBillingOrder(id string, providerRequestID string) err
|
||||
return errors.New("billing order already refunded")
|
||||
}
|
||||
if order.BillingMode == "token" && !zeroPricedTokenOrder(order) {
|
||||
usage, err := billingUsage(tx, id)
|
||||
usage, usageSource, err := tokenSettlementUsage(tx, order)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
observedUsage = usage
|
||||
observedUsageSource = usageSource
|
||||
reserved := order.ReservedAmountMicrocredits
|
||||
if reserved <= 0 {
|
||||
reserved = order.AmountMicrocredits
|
||||
@@ -761,7 +787,7 @@ func (r *Repository) SettleBillingOrder(id string, providerRequestID string) err
|
||||
updates := map[string]any{"status": model.BillingStatusSettled, "settled_at": &now, "updated_at": now,
|
||||
"actual_amount_microcredits": actual, "refunded_amount_microcredits": refund,
|
||||
"input_tokens": usage.InputTokens, "output_tokens": usage.OutputTokens, "cached_tokens": usage.CachedTokens,
|
||||
"usage_available": true}
|
||||
"usage_available": usageSource == billingUsageSourceProvider, "usage_source": usageSource}
|
||||
if providerRequestID != "" {
|
||||
updates["provider_request_id"] = providerRequestID
|
||||
}
|
||||
@@ -774,6 +800,9 @@ func (r *Repository) SettleBillingOrder(id string, providerRequestID string) err
|
||||
} else if supplement > 0 {
|
||||
consumeNote = "Token 实际用量超过预授权,已补扣差额"
|
||||
}
|
||||
if usageSource == billingUsageSourceVideoFormula {
|
||||
consumeNote = strings.TrimSpace("按提交时的视频 Token 公式快照结算;" + consumeNote)
|
||||
}
|
||||
if err := tx.Create(&model.CreditLedgerEntry{ID: newRepositoryID(), UserID: order.UserID, Type: model.CreditLedgerConsume,
|
||||
AmountMicrocredits: -actual, AvailableDeltaMicrocredits: -supplement, ReservedDeltaMicrocredits: -reserved,
|
||||
AvailableAfterMicrocredits: account.AvailableMicrocredits, ReservedAfterMicrocredits: account.ReservedMicrocredits,
|
||||
@@ -830,10 +859,11 @@ func (r *Repository) SettleBillingOrder(id string, providerRequestID string) err
|
||||
}).Error
|
||||
})
|
||||
if err != nil && observedUsage != nil {
|
||||
// usage 是上游已经确认的事实;即使结算因账户状态异常回滚,也要保留给用户和管理员核对。
|
||||
// 即使账户状态异常导致回滚,也保留结算依据并标明来源,供用户和管理员核对。
|
||||
updates := map[string]any{
|
||||
"input_tokens": observedUsage.InputTokens, "output_tokens": observedUsage.OutputTokens,
|
||||
"cached_tokens": observedUsage.CachedTokens, "usage_available": true, "updated_at": time.Now(),
|
||||
"cached_tokens": observedUsage.CachedTokens, "usage_available": observedUsageSource == billingUsageSourceProvider,
|
||||
"usage_source": observedUsageSource, "updated_at": time.Now(),
|
||||
}
|
||||
if observedActualAvailable {
|
||||
updates["actual_amount_microcredits"] = observedActual
|
||||
@@ -873,12 +903,13 @@ func (r *Repository) RestoreRefundedBillingOrder(id string, providerRequestID st
|
||||
|
||||
actual := order.AmountMicrocredits
|
||||
var usage *BillingUsage
|
||||
var usageSource string
|
||||
if order.BillingMode == "token" {
|
||||
if zeroPricedTokenOrder(order) {
|
||||
actual = 0
|
||||
} else {
|
||||
var err error
|
||||
usage, err = billingUsage(tx, id)
|
||||
usage, usageSource, err = tokenSettlementUsage(tx, order)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -888,6 +919,10 @@ func (r *Repository) RestoreRefundedBillingOrder(id string, providerRequestID st
|
||||
}
|
||||
}
|
||||
}
|
||||
chargeCapped := order.BillingMode == "token" && order.ChargeLimitMicrocredits > 0 && actual > order.ChargeLimitMicrocredits
|
||||
if chargeCapped {
|
||||
actual = order.ChargeLimitMicrocredits
|
||||
}
|
||||
if actual < 0 {
|
||||
return errors.New("invalid restored billing amount")
|
||||
}
|
||||
@@ -927,7 +962,8 @@ func (r *Repository) RestoreRefundedBillingOrder(id string, providerRequestID st
|
||||
orderUpdates["input_tokens"] = usage.InputTokens
|
||||
orderUpdates["output_tokens"] = usage.OutputTokens
|
||||
orderUpdates["cached_tokens"] = usage.CachedTokens
|
||||
orderUpdates["usage_available"] = true
|
||||
orderUpdates["usage_available"] = usageSource == billingUsageSourceProvider
|
||||
orderUpdates["usage_source"] = usageSource
|
||||
}
|
||||
orderUpdate := tx.Model(&model.BillingOrder{}).
|
||||
Where("id = ? AND status = ?", order.ID, model.BillingStatusRefunded).
|
||||
@@ -939,6 +975,13 @@ func (r *Repository) RestoreRefundedBillingOrder(id string, providerRequestID st
|
||||
return ErrBillingStateConflict
|
||||
}
|
||||
|
||||
consumeNote := "人工查询确认上游成功,退款订单重新扣费"
|
||||
if usageSource == billingUsageSourceVideoFormula {
|
||||
consumeNote += ";按提交时的视频 Token 公式快照结算"
|
||||
}
|
||||
if chargeCapped {
|
||||
consumeNote += ";已按本轮 Agent 硬上限结算"
|
||||
}
|
||||
return tx.Create(&model.CreditLedgerEntry{
|
||||
ID: newRepositoryID(),
|
||||
UserID: order.UserID,
|
||||
@@ -951,7 +994,7 @@ func (r *Repository) RestoreRefundedBillingOrder(id string, providerRequestID st
|
||||
Model: order.Model,
|
||||
ChannelID: order.ChannelID,
|
||||
Scene: order.Scene,
|
||||
Note: "人工查询确认上游成功,退款订单重新扣费",
|
||||
Note: consumeNote,
|
||||
}).Error
|
||||
})
|
||||
}
|
||||
@@ -1020,38 +1063,28 @@ func tokenUsageAmount(order model.BillingOrder, usage *BillingUsage) (int64, err
|
||||
if usage == nil {
|
||||
return 0, ErrBillingUsageUnavailable
|
||||
}
|
||||
if usage.InputTokens < 0 || usage.OutputTokens < 0 || usage.CachedTokens < 0 {
|
||||
return 0, errors.New("invalid token usage amount")
|
||||
}
|
||||
if order.Capability == "video" && usage.OutputTokens <= 0 {
|
||||
return 0, ErrBillingUsageUnavailable
|
||||
}
|
||||
input := usage.InputTokens - usage.CachedTokens
|
||||
if input < 0 {
|
||||
input = 0
|
||||
terms := []kernel.TokenBillingTerm{
|
||||
{Tokens: usage.OutputTokens, PriceMicrocredits: order.OutputTokenPriceMicrocredits},
|
||||
}
|
||||
inputAmount, ok := safeTokenUsageProduct(input, order.InputTokenPriceMicrocredits)
|
||||
if !ok {
|
||||
// 视频 Token 总用量已由供应商 completion_tokens 表达,不再叠加文本输入和缓存费用。
|
||||
if order.Capability != "video" {
|
||||
input := max(usage.InputTokens-usage.CachedTokens, 0)
|
||||
terms = append(terms,
|
||||
kernel.TokenBillingTerm{Tokens: input, PriceMicrocredits: order.InputTokenPriceMicrocredits},
|
||||
kernel.TokenBillingTerm{Tokens: usage.CachedTokens, PriceMicrocredits: order.CachedTokenPriceMicrocredits},
|
||||
)
|
||||
}
|
||||
amount, err := kernel.TokenBillingAmount(order.MultiplierBasisPoints, terms...)
|
||||
if err != nil {
|
||||
return 0, errors.New("invalid token usage amount")
|
||||
}
|
||||
outputAmount, ok := safeTokenUsageProduct(usage.OutputTokens, order.OutputTokenPriceMicrocredits)
|
||||
if !ok || inputAmount > 1<<63-1-outputAmount {
|
||||
return 0, errors.New("invalid token usage amount")
|
||||
}
|
||||
cachedAmount, ok := safeTokenUsageProduct(usage.CachedTokens, order.CachedTokenPriceMicrocredits)
|
||||
base := inputAmount + outputAmount
|
||||
if !ok || base > 1<<63-1-cachedAmount {
|
||||
return 0, errors.New("invalid token usage amount")
|
||||
}
|
||||
base += cachedAmount
|
||||
if order.MultiplierBasisPoints <= 0 || base > (1<<63-1-9_999_999_999)/order.MultiplierBasisPoints {
|
||||
return 0, errors.New("invalid token usage amount")
|
||||
}
|
||||
return (base*order.MultiplierBasisPoints + 9_999_999_999) / 10_000_000_000, nil
|
||||
}
|
||||
|
||||
func safeTokenUsageProduct(tokens int64, price int64) (int64, bool) {
|
||||
if tokens < 0 || price < 0 || (tokens > 0 && price > (1<<63-1)/tokens) {
|
||||
return 0, false
|
||||
}
|
||||
return tokens * price, true
|
||||
return amount, nil
|
||||
}
|
||||
|
||||
func (r *Repository) AdjustCredits(userID string, actorUserID string, amount int64, note string) (*model.CreditAccount, error) {
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
)
|
||||
|
||||
func TestTokenUsageAmountUsesExactArithmetic(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
order model.BillingOrder
|
||||
usage BillingUsage
|
||||
want int64
|
||||
}{
|
||||
{
|
||||
name: "video maximum price with multiplier ignores text charges",
|
||||
order: model.BillingOrder{
|
||||
Capability: "video", MultiplierBasisPoints: 12_500,
|
||||
InputTokenPriceMicrocredits: 1_000_000_000_000, OutputTokenPriceMicrocredits: 1_000_000_000_000,
|
||||
CachedTokenPriceMicrocredits: 1_000_000_000_000,
|
||||
},
|
||||
usage: BillingUsage{InputTokens: math.MaxInt64, OutputTokens: 10_000_000, CachedTokens: math.MaxInt64},
|
||||
want: 12_500_000_000_000,
|
||||
},
|
||||
{
|
||||
name: "text subtracts cache and preserves distinct prices",
|
||||
order: model.BillingOrder{
|
||||
Capability: "text", MultiplierBasisPoints: 12_500,
|
||||
InputTokenPriceMicrocredits: 500_000_000_000, OutputTokenPriceMicrocredits: 1_000_000_000_000,
|
||||
CachedTokenPriceMicrocredits: 100_000_000_000,
|
||||
},
|
||||
usage: BillingUsage{InputTokens: 20_000_000, OutputTokens: 10_000_000, CachedTokens: 5_000_000},
|
||||
want: 22_500_000_000_000,
|
||||
},
|
||||
{
|
||||
name: "round once after all text charges",
|
||||
order: model.BillingOrder{
|
||||
Capability: "text", MultiplierBasisPoints: 10_000,
|
||||
InputTokenPriceMicrocredits: 400_000, OutputTokenPriceMicrocredits: 400_000, CachedTokenPriceMicrocredits: 100_000,
|
||||
},
|
||||
usage: BillingUsage{InputTokens: 2, OutputTokens: 1, CachedTokens: 1},
|
||||
want: 1,
|
||||
},
|
||||
{
|
||||
name: "maximum final amount",
|
||||
order: model.BillingOrder{Capability: "video", MultiplierBasisPoints: 10_000, OutputTokenPriceMicrocredits: 1_000_000},
|
||||
usage: BillingUsage{OutputTokens: math.MaxInt64}, want: math.MaxInt64,
|
||||
},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
amount, err := tokenUsageAmount(test.order, &test.usage)
|
||||
if err != nil || amount != test.want {
|
||||
t.Fatalf("tokenUsageAmount() = %d, %v; want %d", amount, err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenUsageAmountRejectsInvalidArithmetic(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
order model.BillingOrder
|
||||
usage BillingUsage
|
||||
}{
|
||||
{
|
||||
name: "negative output price",
|
||||
order: model.BillingOrder{Capability: "video", MultiplierBasisPoints: 10_000, OutputTokenPriceMicrocredits: -1},
|
||||
usage: BillingUsage{OutputTokens: 1},
|
||||
},
|
||||
{
|
||||
name: "negative unused text cache price",
|
||||
order: model.BillingOrder{Capability: "text", MultiplierBasisPoints: 10_000, CachedTokenPriceMicrocredits: -1},
|
||||
usage: BillingUsage{OutputTokens: 1},
|
||||
},
|
||||
{
|
||||
name: "zero multiplier",
|
||||
order: model.BillingOrder{Capability: "video", OutputTokenPriceMicrocredits: 1},
|
||||
usage: BillingUsage{OutputTokens: 1},
|
||||
},
|
||||
{
|
||||
name: "final overflow",
|
||||
order: model.BillingOrder{Capability: "video", MultiplierBasisPoints: 10_001, OutputTokenPriceMicrocredits: 1_000_000},
|
||||
usage: BillingUsage{OutputTokens: math.MaxInt64},
|
||||
},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if amount, err := tokenUsageAmount(test.order, &test.usage); amount != 0 || err == nil {
|
||||
t.Fatalf("invalid usage billed %d, %v", amount, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
)
|
||||
|
||||
func TestVideoTokenSettlementChoosesProviderOrFormulaSnapshot(t *testing.T) {
|
||||
for _, restore := range []bool{false, true} {
|
||||
mode := "settle"
|
||||
if restore {
|
||||
mode = "restore"
|
||||
}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
log *model.ApiCallLog
|
||||
formula int64
|
||||
cap int64
|
||||
wantTokens int64
|
||||
wantAmount int64
|
||||
wantSource string
|
||||
wantError bool
|
||||
}{
|
||||
{name: "missing usage", formula: 108000, wantTokens: 108000, wantAmount: 1728000, wantSource: "video_formula"},
|
||||
{name: "zero usage", formula: 108000, log: &model.ApiCallLog{UsageAvailable: true}, wantTokens: 108000, wantAmount: 1728000, wantSource: "video_formula"},
|
||||
{name: "negative usage", formula: 108000, log: &model.ApiCallLog{UsageAvailable: true, OutputTokens: -1}, wantTokens: 108000, wantAmount: 1728000, wantSource: "video_formula"},
|
||||
{name: "unavailable usage", formula: 108000, log: &model.ApiCallLog{OutputTokens: 200000}, wantTokens: 108000, wantAmount: 1728000, wantSource: "video_formula"},
|
||||
{name: "provider refund", formula: 108000, log: &model.ApiCallLog{UsageAvailable: true, OutputTokens: 100000, InputTokens: 20, CachedTokens: 10}, wantTokens: 100000, wantAmount: 1600000, wantSource: "provider"},
|
||||
{name: "provider supplement", formula: 108000, log: &model.ApiCallLog{UsageAvailable: true, OutputTokens: 200000}, wantTokens: 200000, wantAmount: 3200000, wantSource: "provider"},
|
||||
{name: "provider cap", formula: 108000, cap: 1000000, log: &model.ApiCallLog{UsageAvailable: true, OutputTokens: 200000}, wantTokens: 200000, wantAmount: 1000000, wantSource: "provider"},
|
||||
{name: "formula cap", formula: 108000, cap: 1000000, wantTokens: 108000, wantAmount: 1000000, wantSource: "video_formula"},
|
||||
{name: "legacy no usage", wantError: true},
|
||||
{name: "invalid snapshot", formula: -1, wantError: true},
|
||||
} {
|
||||
t.Run(mode+"/"+test.name, func(t *testing.T) {
|
||||
repo, db := newRefundedBillingRecoveryRepository(t)
|
||||
const available = int64(10000000)
|
||||
const reserved = int64(1900800)
|
||||
order := model.BillingOrder{
|
||||
ID: "order", UserID: "user", IdempotencyKey: "task", Capability: "video", BillingMode: "token",
|
||||
Quantity: 118800, VideoFormulaTokens: test.formula, AmountMicrocredits: reserved,
|
||||
ReservedAmountMicrocredits: reserved, OutputTokenPriceMicrocredits: 16000000,
|
||||
MultiplierBasisPoints: 10000, ChargeLimitMicrocredits: test.cap, Status: model.BillingStatusRunning,
|
||||
}
|
||||
account := model.CreditAccount{UserID: "user", AvailableMicrocredits: available, ReservedMicrocredits: reserved}
|
||||
settle := repo.SettleBillingOrder
|
||||
if restore {
|
||||
settle = repo.RestoreRefundedBillingOrder
|
||||
order.Status = model.BillingStatusRefunded
|
||||
order.RefundedAmountMicrocredits = reserved
|
||||
account.ReservedMicrocredits = 0
|
||||
account.AvailableMicrocredits += reserved
|
||||
}
|
||||
if err := db.Create(&account).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&order).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if test.log != nil {
|
||||
log := *test.log
|
||||
log.ID, log.BillingOrderID, log.Status = "log", "order", model.ApiCallStatusSucceeded
|
||||
if err := db.Create(&log).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := settle("order", "provider-id"); test.wantError {
|
||||
if !errors.Is(err, ErrBillingUsageUnavailable) {
|
||||
t.Fatalf("expected unavailable usage, got %v", err)
|
||||
}
|
||||
var after model.CreditAccount
|
||||
if err := db.First(&after, "user_id = ?", "user").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if after.AvailableMicrocredits != account.AvailableMicrocredits || after.ReservedMicrocredits != account.ReservedMicrocredits {
|
||||
t.Fatalf("rejected settlement changed balance: %+v", after)
|
||||
}
|
||||
return
|
||||
} else if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := settle("order", "provider-id"); err != nil {
|
||||
t.Fatalf("repeat settlement: %v", err)
|
||||
}
|
||||
if err := db.First(&order, "id = ?", "order").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if order.Status != model.BillingStatusSettled || order.ActualAmountMicrocredits != test.wantAmount || order.OutputTokens != test.wantTokens || order.UsageSource != test.wantSource || order.UsageAvailable != (test.wantSource == "provider") || order.InputTokens != 0 || order.CachedTokens != 0 {
|
||||
t.Fatalf("unexpected settled order: %+v", order)
|
||||
}
|
||||
if order.VideoFormulaTokens != test.formula || order.Quantity != 118800 || order.RefundedAmountMicrocredits != max(reserved-test.wantAmount, 0) {
|
||||
t.Fatalf("snapshot or refund changed: %+v", order)
|
||||
}
|
||||
if err := db.First(&account, "user_id = ?", "user").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if account.AvailableMicrocredits != available+reserved-test.wantAmount || account.ReservedMicrocredits != 0 {
|
||||
t.Fatalf("unexpected account after settlement: %+v", account)
|
||||
}
|
||||
var entries []model.CreditLedgerEntry
|
||||
if err := db.Where("billing_order_id = ? AND type = ?", "order", model.CreditLedgerConsume).Find(&entries).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(entries) != 1 || entries[0].AmountMicrocredits != -test.wantAmount {
|
||||
t.Fatalf("settlement not idempotent: %+v", entries)
|
||||
}
|
||||
if test.wantSource == "video_formula" && !strings.Contains(entries[0].Note, "公式") {
|
||||
t.Fatalf("formula source not recorded: %+v", entries[0])
|
||||
}
|
||||
if test.cap > 0 && !strings.Contains(entries[0].Note, "硬上限") {
|
||||
t.Fatalf("charge cap not recorded: %+v", entries[0])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVideoTokenSettlementDoesNotHideDatabaseErrors(t *testing.T) {
|
||||
for _, restore := range []bool{false, true} {
|
||||
repo, db := newRefundedBillingRecoveryRepository(t)
|
||||
if err := db.Migrator().DropTable(&model.ApiCallLog{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
order := model.BillingOrder{ID: "order", UserID: "user", IdempotencyKey: "task", Capability: "video", BillingMode: "token", VideoFormulaTokens: 108000, AmountMicrocredits: 1900800, ReservedAmountMicrocredits: 1900800, OutputTokenPriceMicrocredits: 16000000, MultiplierBasisPoints: 10000, Status: model.BillingStatusRunning}
|
||||
settle := repo.SettleBillingOrder
|
||||
if restore {
|
||||
order.Status = model.BillingStatusRefunded
|
||||
settle = repo.RestoreRefundedBillingOrder
|
||||
}
|
||||
if err := db.Create(&order).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := settle("order", ""); err == nil || errors.Is(err, ErrBillingUsageUnavailable) {
|
||||
t.Fatalf("database failure was hidden: %v", err)
|
||||
}
|
||||
if err := db.First(&order, "id = ?", "order").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if order.Status == model.BillingStatusSettled || order.UsageSource != "" || order.ActualAmountMicrocredits != 0 {
|
||||
t.Fatalf("database failure changed settlement: %+v", order)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFreeVideoTokenSettlementDoesNotRequireUsage(t *testing.T) {
|
||||
for _, restore := range []bool{false, true} {
|
||||
repo, db := newRefundedBillingRecoveryRepository(t)
|
||||
if err := db.Migrator().DropTable(&model.ApiCallLog{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
order := model.BillingOrder{ID: "order", UserID: "user", IdempotencyKey: "task", Capability: "video", BillingMode: "token", VideoFormulaTokens: 108000, Status: model.BillingStatusRunning}
|
||||
settle := repo.SettleBillingOrder
|
||||
if restore {
|
||||
order.Status = model.BillingStatusRefunded
|
||||
settle = repo.RestoreRefundedBillingOrder
|
||||
}
|
||||
if err := db.Create(&order).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&model.CreditAccount{UserID: "user", AvailableMicrocredits: 123}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := settle("order", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.First(&order, "id = ?", "order").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if order.Status != model.BillingStatusSettled || order.ActualAmountMicrocredits != 0 || order.UsageAvailable {
|
||||
t.Fatalf("unexpected free settlement: %+v", order)
|
||||
}
|
||||
var account model.CreditAccount
|
||||
if err := db.First(&account, "user_id = ?", "user").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if account.AvailableMicrocredits != 123 || account.ReservedMicrocredits != 0 {
|
||||
t.Fatalf("free order charged account: %+v", account)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
)
|
||||
|
||||
func TestVideoTokenUsageAmountIgnoresTextCharges(t *testing.T) {
|
||||
order := model.BillingOrder{
|
||||
Capability: "video", InputTokenPriceMicrocredits: 30_000_000,
|
||||
OutputTokenPriceMicrocredits: 16_000_000, CachedTokenPriceMicrocredits: 20_000_000,
|
||||
MultiplierBasisPoints: 10_000,
|
||||
}
|
||||
usage := &BillingUsage{InputTokens: 1000, OutputTokens: 108900, CachedTokens: 200}
|
||||
amount, err := tokenUsageAmount(order, usage)
|
||||
if err != nil || amount != 1_742_400 {
|
||||
t.Fatalf("video settlement = %d, %v, want 1742400", amount, err)
|
||||
}
|
||||
if usage.InputTokens != 1000 || usage.CachedTokens != 200 {
|
||||
t.Fatalf("settlement mutated observed usage: %#v", usage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVideoTokenUsageAmountRejectsInvalidUsage(t *testing.T) {
|
||||
order := model.BillingOrder{Capability: "video", OutputTokenPriceMicrocredits: 16_000_000, MultiplierBasisPoints: 10_000}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
usage *BillingUsage
|
||||
}{
|
||||
{name: "missing"},
|
||||
{name: "zero output", usage: &BillingUsage{}},
|
||||
{name: "negative output", usage: &BillingUsage{OutputTokens: -1}},
|
||||
{name: "negative input", usage: &BillingUsage{InputTokens: -1, OutputTokens: 108900}},
|
||||
{name: "negative cached", usage: &BillingUsage{CachedTokens: -1, OutputTokens: 108900}},
|
||||
{name: "overflow", usage: &BillingUsage{OutputTokens: 1<<63 - 1}},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if amount, err := tokenUsageAmount(order, test.usage); err == nil {
|
||||
t.Fatalf("invalid usage settled to %d", amount)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextTokenUsageAmountPreservesInputOutputAndCachePrices(t *testing.T) {
|
||||
amount, err := tokenUsageAmount(model.BillingOrder{
|
||||
Capability: "text", InputTokenPriceMicrocredits: 2_000_000,
|
||||
OutputTokenPriceMicrocredits: 5_000_000, CachedTokenPriceMicrocredits: 1_000_000,
|
||||
MultiplierBasisPoints: 10_000,
|
||||
}, &BillingUsage{InputTokens: 100, OutputTokens: 20, CachedTokens: 40})
|
||||
if err != nil || amount != 260 {
|
||||
t.Fatalf("text settlement = %d, %v, want 260", amount, err)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user