feat: add billing and usage domain cores
This commit is contained in:
1 parent
48dd5d07c8
commit
5e60bb40e7
14 files changed
+931
No files matched your search
@@ -0,0 +1,253 @@
|
||||
package billing
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const CurrencyCNY = "CNY"
|
||||
|
||||
var (
|
||||
ErrAmbiguousPriceRule = errors.New("billing price rules are ambiguous")
|
||||
ErrPriceRuleNotFound = errors.New("billing price rule not found")
|
||||
ErrParameterTier = errors.New("billing parameter tier not found")
|
||||
)
|
||||
|
||||
type Unit string
|
||||
|
||||
const (
|
||||
UnitRequest Unit = "request"
|
||||
UnitImage Unit = "image"
|
||||
UnitVideoSecond Unit = "video_second"
|
||||
)
|
||||
|
||||
type QuantitySource string
|
||||
|
||||
const (
|
||||
QuantityRequest QuantitySource = "request"
|
||||
QuantityImageCount QuantitySource = "image_count"
|
||||
QuantityDuration QuantitySource = "duration"
|
||||
)
|
||||
|
||||
type Parameters map[string]any
|
||||
type Conditions map[string]any
|
||||
type ConditionRange struct {
|
||||
Min *float64
|
||||
Max *float64
|
||||
Values []any
|
||||
}
|
||||
|
||||
type ParameterTier struct {
|
||||
Value any
|
||||
Match any
|
||||
StandardFactor, MarkupMultiplier float64
|
||||
Enabled bool
|
||||
}
|
||||
type ParameterDimension struct {
|
||||
Key string
|
||||
DefaultValue any
|
||||
BaselineValue any
|
||||
Tiers []ParameterTier
|
||||
}
|
||||
type PriceRule struct {
|
||||
ID, Provider, Capability, ReqKey string
|
||||
Unit Unit
|
||||
QuantitySource QuantitySource
|
||||
StandardUnitPriceFen int64
|
||||
MarkupMultiplier float64
|
||||
Enabled bool
|
||||
Conditions Conditions
|
||||
Priority int
|
||||
Dimensions []ParameterDimension
|
||||
}
|
||||
type QuoteInput struct {
|
||||
Provider, Capability, ReqKey, Source, Role string
|
||||
Parameters Parameters
|
||||
BillingDisabled bool
|
||||
}
|
||||
type Quote struct {
|
||||
PriceRuleID string
|
||||
Unit Unit
|
||||
Quantity float64
|
||||
StandardUnitPriceFen, AmountFen int64
|
||||
MarkupMultiplier float64
|
||||
Currency string
|
||||
QuotaExempt bool
|
||||
}
|
||||
type Catalog struct{ Rules []PriceRule }
|
||||
|
||||
func (c Catalog) Quote(input QuoteInput) (*Quote, error) {
|
||||
if input.BillingDisabled || input.Provider == "mock" {
|
||||
return nil, nil
|
||||
}
|
||||
type candidate struct {
|
||||
rule PriceRule
|
||||
req, conditions, priority int
|
||||
}
|
||||
var matches []candidate
|
||||
for _, rule := range c.Rules {
|
||||
if !rule.Enabled || rule.Provider != input.Provider || rule.Capability != input.Capability || rule.ReqKey != "" && rule.ReqKey != input.ReqKey || !conditionsMatch(rule.Conditions, input.Parameters) {
|
||||
continue
|
||||
}
|
||||
req := 0
|
||||
if rule.ReqKey != "" {
|
||||
req = 1
|
||||
}
|
||||
matches = append(matches, candidate{rule, req, len(rule.Conditions), rule.Priority})
|
||||
}
|
||||
if len(matches) == 0 {
|
||||
return nil, ErrPriceRuleNotFound
|
||||
}
|
||||
sort.Slice(matches, func(i, j int) bool {
|
||||
a, b := matches[i], matches[j]
|
||||
if a.req != b.req {
|
||||
return a.req > b.req
|
||||
}
|
||||
if a.conditions != b.conditions {
|
||||
return a.conditions > b.conditions
|
||||
}
|
||||
if a.priority != b.priority {
|
||||
return a.priority > b.priority
|
||||
}
|
||||
return a.rule.ID < b.rule.ID
|
||||
})
|
||||
w := matches[0]
|
||||
if len(matches) > 1 && matches[1].req == w.req && matches[1].conditions == w.conditions && matches[1].priority == w.priority {
|
||||
return nil, fmt.Errorf("%w: %s and %s", ErrAmbiguousPriceRule, w.rule.ID, matches[1].rule.ID)
|
||||
}
|
||||
price, markup, err := tierPrice(w.rule, input.Parameters)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
quantity := quantityFor(w.rule, input.Parameters)
|
||||
return &Quote{PriceRuleID: w.rule.ID, Unit: w.rule.Unit, Quantity: quantity, StandardUnitPriceFen: price, MarkupMultiplier: markup, AmountFen: int64(math.Ceil(float64(price) * quantity * markup)), Currency: CurrencyCNY, QuotaExempt: input.Source == "platform" && input.Role == "super_admin"}, nil
|
||||
}
|
||||
|
||||
func tierPrice(rule PriceRule, params Parameters) (int64, float64, error) {
|
||||
if len(rule.Dimensions) == 0 {
|
||||
return rule.StandardUnitPriceFen, rule.MarkupMultiplier, nil
|
||||
}
|
||||
factor, markup := 1.0, 1.0
|
||||
for _, dimension := range rule.Dimensions {
|
||||
actual, ok := params[dimension.Key]
|
||||
if !ok {
|
||||
actual = dimension.DefaultValue
|
||||
if actual == nil {
|
||||
actual = dimension.BaselineValue
|
||||
}
|
||||
}
|
||||
found := false
|
||||
for _, tier := range dimension.Tiers {
|
||||
if tier.Enabled && conditionMatches(firstNonNil(tier.Match, tier.Value), actual) {
|
||||
if tier.StandardFactor <= 0 || tier.MarkupMultiplier < 1 {
|
||||
return 0, 0, ErrParameterTier
|
||||
}
|
||||
factor *= tier.StandardFactor
|
||||
markup = math.Max(markup, tier.MarkupMultiplier)
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return 0, 0, ErrParameterTier
|
||||
}
|
||||
}
|
||||
return int64(math.Ceil(float64(rule.StandardUnitPriceFen) * factor)), markup, nil
|
||||
}
|
||||
func quantityFor(rule PriceRule, params Parameters) float64 {
|
||||
source := rule.QuantitySource
|
||||
if source == "" {
|
||||
if rule.Unit == UnitImage {
|
||||
source = QuantityImageCount
|
||||
} else if rule.Unit == UnitVideoSecond {
|
||||
source = QuantityDuration
|
||||
} else {
|
||||
source = QuantityRequest
|
||||
}
|
||||
}
|
||||
if source == QuantityRequest {
|
||||
return 1
|
||||
}
|
||||
key := "imageCount"
|
||||
if source == QuantityDuration {
|
||||
key = "duration"
|
||||
}
|
||||
n, ok := number(params[key])
|
||||
if !ok || n <= 0 {
|
||||
return 1
|
||||
}
|
||||
return math.Ceil(n)
|
||||
}
|
||||
func conditionsMatch(cs Conditions, p Parameters) bool {
|
||||
for k, c := range cs {
|
||||
if !conditionMatches(c, p[k]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
func conditionMatches(condition, actual any) bool {
|
||||
if actual == nil {
|
||||
return false
|
||||
}
|
||||
switch c := condition.(type) {
|
||||
case ConditionRange:
|
||||
n, ok := number(actual)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if c.Min != nil && n < *c.Min {
|
||||
return false
|
||||
}
|
||||
if c.Max != nil && n > *c.Max {
|
||||
return false
|
||||
}
|
||||
for _, v := range c.Values {
|
||||
if scalarEqual(v, actual) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return len(c.Values) == 0
|
||||
case map[string]any:
|
||||
n, ok := number(actual)
|
||||
if min, yes := number(c["min"]); yes && (!ok || n < min) {
|
||||
return false
|
||||
}
|
||||
if max, yes := number(c["max"]); yes && (!ok || n > max) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
default:
|
||||
return scalarEqual(condition, actual)
|
||||
}
|
||||
}
|
||||
func scalarEqual(a, b any) bool {
|
||||
if x, ok := number(a); ok {
|
||||
y, ok := number(b)
|
||||
return ok && x == y
|
||||
}
|
||||
return strings.EqualFold(strings.TrimSpace(fmt.Sprint(a)), strings.TrimSpace(fmt.Sprint(b)))
|
||||
}
|
||||
func number(v any) (float64, bool) {
|
||||
switch n := v.(type) {
|
||||
case int:
|
||||
return float64(n), true
|
||||
case int64:
|
||||
return float64(n), true
|
||||
case float64:
|
||||
return n, !math.IsNaN(n) && !math.IsInf(n, 0)
|
||||
case float32:
|
||||
return float64(n), true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
func firstNonNil(a, b any) any {
|
||||
if a != nil {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package billing
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBillingCoreFixtureIsConsumedByGo(t *testing.T) {
|
||||
data, err := os.ReadFile("../../../contracts/billing/core-v1.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var fixture struct {
|
||||
Currency string `json:"currency"`
|
||||
MoneyUnit string `json:"moneyUnit"`
|
||||
Statuses struct {
|
||||
InsufficientBalance int `json:"insufficientBalance"`
|
||||
IdempotencyConflict int `json:"idempotencyConflict"`
|
||||
} `json:"statuses"`
|
||||
IdempotencyKeys struct {
|
||||
Charge string `json:"charge"`
|
||||
Refund string `json:"refund"`
|
||||
} `json:"idempotencyKeys"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &fixture); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fixture.Currency != CurrencyCNY || fixture.MoneyUnit != "fen" || fixture.Statuses.InsufficientBalance != 402 || fixture.Statuses.IdempotencyConflict != 409 || fixture.IdempotencyKeys.Charge != "job-charge:{jobId}" || fixture.IdempotencyKeys.Refund != "job-refund:{jobId}" {
|
||||
t.Fatalf("fixture = %#v", fixture)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogQuotesMostSpecificRuleWithCeiledQuantityAndTierFactors(t *testing.T) {
|
||||
catalog := Catalog{Rules: []PriceRule{
|
||||
{ID: "generic", Provider: "evolink", Capability: "image.generate", Unit: UnitImage, StandardUnitPriceFen: 10, MarkupMultiplier: 1.2, Enabled: true},
|
||||
{ID: "specific", Provider: "evolink", Capability: "image.generate", ReqKey: "gpt-image-2", Unit: UnitImage, QuantitySource: QuantityImageCount, StandardUnitPriceFen: 34, MarkupMultiplier: 1.2, Enabled: true, Conditions: Conditions{"quality": "high"}, Dimensions: []ParameterDimension{
|
||||
{Key: "quality", Tiers: []ParameterTier{{Value: "high", StandardFactor: 4, MarkupMultiplier: 1.5, Enabled: true}}},
|
||||
{Key: "resolution", DefaultValue: "1K", Tiers: []ParameterTier{{Value: "1K", StandardFactor: 2, MarkupMultiplier: 1.8, Enabled: true}}},
|
||||
}},
|
||||
}}
|
||||
quote, err := catalog.Quote(QuoteInput{Provider: "evolink", Capability: "image.generate", ReqKey: "gpt-image-2", Parameters: Parameters{"quality": "HIGH", "imageCount": 1.2}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if quote.PriceRuleID != "specific" || quote.Quantity != 2 || quote.StandardUnitPriceFen != 272 || quote.MarkupMultiplier != 1.8 || quote.AmountFen != 980 || quote.Currency != CurrencyCNY {
|
||||
t.Fatalf("quote = %#v", quote)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogRejectsAmbiguousWinners(t *testing.T) {
|
||||
rules := []PriceRule{
|
||||
{ID: "a", Provider: "bailian", Capability: "image.generate", Unit: UnitRequest, StandardUnitPriceFen: 1, MarkupMultiplier: 1, Enabled: true},
|
||||
{ID: "b", Provider: "bailian", Capability: "image.generate", Unit: UnitRequest, StandardUnitPriceFen: 2, MarkupMultiplier: 1, Enabled: true},
|
||||
}
|
||||
_, err := (Catalog{Rules: rules}).Quote(QuoteInput{Provider: "bailian", Capability: "image.generate"})
|
||||
if !errors.Is(err, ErrAmbiguousPriceRule) {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogExemptsDisabledMockAndSuperAdmin(t *testing.T) {
|
||||
rule := PriceRule{ID: "r", Provider: "bailian", Capability: "image.generate", Unit: UnitRequest, StandardUnitPriceFen: 10, MarkupMultiplier: 1, Enabled: true}
|
||||
for _, input := range []QuoteInput{
|
||||
{BillingDisabled: true, Provider: "bailian", Capability: "image.generate"},
|
||||
{Provider: "mock", Capability: "image.generate"},
|
||||
} {
|
||||
quote, err := (Catalog{Rules: []PriceRule{rule}}).Quote(input)
|
||||
if err != nil || quote != nil {
|
||||
t.Fatalf("Quote(%#v) = %#v, %v", input, quote, err)
|
||||
}
|
||||
}
|
||||
quote, err := (Catalog{Rules: []PriceRule{rule}}).Quote(QuoteInput{Provider: "bailian", Capability: "image.generate", Source: "platform", Role: "super_admin"})
|
||||
if err != nil || quote == nil || !quote.QuotaExempt {
|
||||
t.Fatalf("quote = %#v, %v", quote, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package billing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInsufficientBalance = errors.New("insufficient billing balance")
|
||||
ErrIdempotencyConflict = errors.New("billing idempotency conflict")
|
||||
)
|
||||
|
||||
type WalletPostParams struct {
|
||||
LedgerID, OrganizationID, AccountID, JobID, Kind string
|
||||
DeltaFen int64
|
||||
Currency, IdempotencyKey, Description string
|
||||
Metadata map[string]any
|
||||
}
|
||||
type WalletPosting struct {
|
||||
LedgerID string
|
||||
BalanceAfterFen, BalanceFen, TotalRechargedFen, TotalChargedFen, DeltaFen int64
|
||||
CreatedAt, UpdatedAt time.Time
|
||||
}
|
||||
type WalletPoster interface {
|
||||
PostWalletEntry(context.Context, WalletPostParams) (WalletPosting, error)
|
||||
}
|
||||
type Ledger struct {
|
||||
Poster WalletPoster
|
||||
NewID func() string
|
||||
}
|
||||
type ChargeRequest struct {
|
||||
OrganizationID, AccountID, JobID, Description string
|
||||
AmountFen int64
|
||||
Metadata map[string]any
|
||||
}
|
||||
type RefundRequest = ChargeRequest
|
||||
|
||||
type StatusError struct {
|
||||
Status int
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *StatusError) Error() string { return e.Err.Error() }
|
||||
func (e *StatusError) Unwrap() error { return e.Err }
|
||||
func HTTPStatus(err error) int {
|
||||
var status *StatusError
|
||||
if errors.As(err, &status) {
|
||||
return status.Status
|
||||
}
|
||||
return 500
|
||||
}
|
||||
|
||||
func (l Ledger) Charge(ctx context.Context, in ChargeRequest) (WalletPosting, error) {
|
||||
return l.post(ctx, in, "charge", -in.AmountFen, "job-charge:"+in.JobID)
|
||||
}
|
||||
func (l Ledger) Refund(ctx context.Context, in RefundRequest) (WalletPosting, error) {
|
||||
return l.post(ctx, in, "refund", in.AmountFen, "job-refund:"+in.JobID)
|
||||
}
|
||||
func (l Ledger) post(ctx context.Context, in ChargeRequest, kind string, delta int64, key string) (WalletPosting, error) {
|
||||
if l.Poster == nil || l.NewID == nil {
|
||||
return WalletPosting{}, errors.New("billing ledger is not configured")
|
||||
}
|
||||
if in.AmountFen <= 0 {
|
||||
return WalletPosting{}, errors.New("billing amount must be positive")
|
||||
}
|
||||
posting, err := l.Poster.PostWalletEntry(ctx, WalletPostParams{LedgerID: l.NewID(), OrganizationID: in.OrganizationID, AccountID: in.AccountID, JobID: in.JobID, Kind: kind, DeltaFen: delta, Currency: CurrencyCNY, IdempotencyKey: key, Description: in.Description, Metadata: in.Metadata})
|
||||
if err == nil {
|
||||
return posting, nil
|
||||
}
|
||||
message := err.Error()
|
||||
if strings.Contains(message, "BILLING_INSUFFICIENT_BALANCE") {
|
||||
return WalletPosting{}, &StatusError{402, fmt.Errorf("%w: %v", ErrInsufficientBalance, err)}
|
||||
}
|
||||
if strings.Contains(message, "BILLING_IDEMPOTENCY_PAYLOAD_MISMATCH") {
|
||||
return WalletPosting{}, &StatusError{409, fmt.Errorf("%w: %v", ErrIdempotencyConflict, err)}
|
||||
}
|
||||
return WalletPosting{}, err
|
||||
}
|
||||
|
||||
func encodeMetadata(value map[string]any) (json.RawMessage, error) {
|
||||
if value == nil {
|
||||
return json.RawMessage(nil), nil
|
||||
}
|
||||
return json.Marshal(value)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package billing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLedgerChargesAndRefundsThroughWalletPosterWithFrozenKeys(t *testing.T) {
|
||||
poster := &recordingPoster{result: WalletPosting{LedgerID: "ledger-1", CreatedAt: time.Unix(1, 0)}}
|
||||
ledger := Ledger{Poster: poster, NewID: func() string { return "new-ledger" }}
|
||||
charge, err := ledger.Charge(context.Background(), ChargeRequest{OrganizationID: "org-1", AccountID: "a", JobID: "job-1", AmountFen: 125})
|
||||
if err != nil || charge.LedgerID != "ledger-1" || poster.params.IdempotencyKey != "job-charge:job-1" || poster.params.DeltaFen != -125 || poster.params.Currency != "CNY" {
|
||||
t.Fatalf("charge = %#v params=%#v err=%v", charge, poster.params, err)
|
||||
}
|
||||
_, err = ledger.Refund(context.Background(), RefundRequest{OrganizationID: "org-1", AccountID: "a", JobID: "job-1", AmountFen: 125})
|
||||
if err != nil || poster.params.IdempotencyKey != "job-refund:job-1" || poster.params.DeltaFen != 125 {
|
||||
t.Fatalf("refund params=%#v err=%v", poster.params, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLedgerMapsWalletFailuresToFrozenStatuses(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
db error
|
||||
want error
|
||||
status int
|
||||
}{{errors.New("BILLING_INSUFFICIENT_BALANCE"), ErrInsufficientBalance, 402}, {errors.New("BILLING_IDEMPOTENCY_PAYLOAD_MISMATCH"), ErrIdempotencyConflict, 409}} {
|
||||
ledger := Ledger{Poster: &recordingPoster{err: test.db}, NewID: func() string { return "id" }}
|
||||
_, err := ledger.Charge(context.Background(), ChargeRequest{OrganizationID: "o", JobID: "j", AmountFen: 1})
|
||||
if !errors.Is(err, test.want) || HTTPStatus(err) != test.status {
|
||||
t.Fatalf("error=%v status=%d", err, HTTPStatus(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type recordingPoster struct {
|
||||
params WalletPostParams
|
||||
result WalletPosting
|
||||
err error
|
||||
}
|
||||
|
||||
func (p *recordingPoster) PostWalletEntry(_ context.Context, v WalletPostParams) (WalletPosting, error) {
|
||||
p.params = v
|
||||
return p.result, p.err
|
||||
}
|
||||
Reference in new issue
Block a user