feat: add billing and usage domain cores

This commit is contained in:
zn-admin committed 2026-08-13 16:27:55 +08:00
1 parent 48dd5d07c8
commit 5e60bb40e7
14 files changed
+931

No files matched your search

+253
View File
@@ -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
}
+78
View File
@@ -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)
}
}
+89
View File
@@ -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)
}
+46
View File
@@ -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
}