chore: establish local platform baseline
This commit is contained in:
@@ -0,0 +1,526 @@
|
||||
package billing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"data-terminal-cloud/internal/audit"
|
||||
"data-terminal-cloud/internal/idempotency"
|
||||
"data-terminal-cloud/internal/identity"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalid = errors.New("billing input is invalid")
|
||||
ErrAccessDenied = errors.New("billing operations access denied")
|
||||
ErrNotFound = errors.New("billing resource not found")
|
||||
ErrStateConflict = errors.New("billing resource state conflict")
|
||||
ErrVersionConflict = errors.New("billing resource version conflict")
|
||||
ErrSelfApproval = errors.New("billing price creator cannot publish own version")
|
||||
)
|
||||
|
||||
var codePattern = regexp.MustCompile(`^[a-z][a-z0-9_.-]{1,79}$`)
|
||||
|
||||
type RateInput struct {
|
||||
UsageCategory string `json:"usage_category"`
|
||||
MetricCode string `json:"metric_code"`
|
||||
Unit string `json:"unit"`
|
||||
UnitPriceMicros int64 `json:"unit_price_micros"`
|
||||
}
|
||||
|
||||
type CreatePriceBookInput struct {
|
||||
TenantID string `json:"-"`
|
||||
Provider string `json:"provider"`
|
||||
Currency string `json:"currency"`
|
||||
SourceRef string `json:"source_ref"`
|
||||
ChangeSummary string `json:"change_summary"`
|
||||
EffectiveFrom time.Time `json:"effective_from"`
|
||||
Rates []RateInput `json:"rates"`
|
||||
}
|
||||
|
||||
type PriceRate struct {
|
||||
ID string `json:"id"`
|
||||
UsageCategory string `json:"usage_category"`
|
||||
MetricCode string `json:"metric_code"`
|
||||
Unit string `json:"unit"`
|
||||
UnitPriceMicros int64 `json:"unit_price_micros"`
|
||||
}
|
||||
|
||||
type PriceBook struct {
|
||||
ID string `json:"id"`
|
||||
TenantID string `json:"tenant_id"`
|
||||
Provider string `json:"provider"`
|
||||
Currency string `json:"currency"`
|
||||
VersionNo int `json:"version_no"`
|
||||
Status string `json:"status"`
|
||||
SourceRef string `json:"source_ref"`
|
||||
ChangeSummary string `json:"change_summary"`
|
||||
EffectiveFrom time.Time `json:"effective_from"`
|
||||
CreatedBy string `json:"created_by"`
|
||||
PublishedBy *string `json:"published_by,omitempty"`
|
||||
PublishedAt *time.Time `json:"published_at,omitempty"`
|
||||
RetiredAt *time.Time `json:"retired_at,omitempty"`
|
||||
RowVersion int64 `json:"row_version"`
|
||||
Rates []PriceRate `json:"rates"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type QuotaInput struct {
|
||||
ScopeType string `json:"scope_type"`
|
||||
ScopeID string `json:"scope_id,omitempty"`
|
||||
BillingTimezone string `json:"billing_timezone"`
|
||||
Currency string `json:"currency"`
|
||||
DailyLimitMicros int64 `json:"daily_limit_micros"`
|
||||
MonthlyLimitMicros int64 `json:"monthly_limit_micros"`
|
||||
MaxConcurrentCalls int `json:"max_concurrent_calls"`
|
||||
MaxCallDurationSec int `json:"max_call_duration_sec"`
|
||||
InProgressAction string `json:"in_progress_action"`
|
||||
ChangeSummary string `json:"change_summary"`
|
||||
}
|
||||
|
||||
type QuotaPolicy struct {
|
||||
ID string `json:"id"`
|
||||
TenantID string `json:"tenant_id"`
|
||||
ScopeType string `json:"scope_type"`
|
||||
ScopeID *string `json:"scope_id,omitempty"`
|
||||
VersionNo int `json:"version_no"`
|
||||
Status string `json:"status"`
|
||||
BillingTimezone string `json:"billing_timezone"`
|
||||
Currency string `json:"currency"`
|
||||
DailyLimitMicros int64 `json:"daily_limit_micros"`
|
||||
MonthlyLimitMicros int64 `json:"monthly_limit_micros"`
|
||||
MaxConcurrentCalls int `json:"max_concurrent_calls"`
|
||||
MaxCallDurationSec int `json:"max_call_duration_sec"`
|
||||
InProgressAction string `json:"in_progress_action"`
|
||||
ChangeSummary string `json:"change_summary"`
|
||||
CreatedBy string `json:"created_by"`
|
||||
RetiredAt *time.Time `json:"retired_at,omitempty"`
|
||||
RowVersion int64 `json:"row_version"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type actor struct {
|
||||
tenantID, tenantPublicID, userID, userPublicID, role string
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
pool *pgxpool.Pool
|
||||
audit *audit.Repository
|
||||
idem *idempotency.Executor
|
||||
}
|
||||
|
||||
func NewService(pool *pgxpool.Pool, auditRepository *audit.Repository) (*Service, error) {
|
||||
if pool == nil || auditRepository == nil {
|
||||
return nil, errors.New("billing service dependencies are required")
|
||||
}
|
||||
return &Service{pool: pool, audit: auditRepository, idem: idempotency.NewExecutor(pool)}, nil
|
||||
}
|
||||
|
||||
func (s *Service) CreatePriceBook(ctx context.Context, principal identity.Principal, key, requestID string, input CreatePriceBookInput) (PriceBook, bool, error) {
|
||||
normalizePriceInput(&input)
|
||||
if err := validatePriceInput(input); err != nil {
|
||||
return PriceBook{}, false, err
|
||||
}
|
||||
currentActor, err := s.resolveActor(ctx, principal, input.TenantID, "ops_operator", "ops_approver", "ops_admin")
|
||||
if err != nil {
|
||||
return PriceBook{}, false, err
|
||||
}
|
||||
requestBody, _ := json.Marshal(input)
|
||||
result, err := s.idem.Execute(ctx, idempotency.Request{TenantID: ¤tActor.tenantID, ActorKey: "ops:" + currentActor.userPublicID, Scope: "POST /api/v1/ops/price-books", Key: key, RequestHash: idempotency.Hash(requestBody), ExpiresAfter: 24 * time.Hour}, func(operationContext context.Context, tx pgx.Tx) (idempotency.Result, error) {
|
||||
if _, queryErr := tx.Exec(operationContext, `SELECT pg_advisory_xact_lock(hashtextextended($1,0))`, currentActor.tenantID+":"+input.Provider); queryErr != nil {
|
||||
return idempotency.Result{}, queryErr
|
||||
}
|
||||
var versionNo int
|
||||
if queryErr := tx.QueryRow(operationContext, `SELECT COALESCE(MAX(version_no),0)+1 FROM billing_price_book WHERE tenant_id=$1::uuid AND provider=$2`, currentActor.tenantID, input.Provider).Scan(&versionNo); queryErr != nil {
|
||||
return idempotency.Result{}, queryErr
|
||||
}
|
||||
publicID := uuid.NewString()
|
||||
var internalID string
|
||||
if queryErr := tx.QueryRow(operationContext, `INSERT INTO billing_price_book(public_id,tenant_id,provider,currency,version_no,source_ref,change_summary,effective_from,created_by) VALUES($1::uuid,$2::uuid,$3,$4,$5,$6,$7,$8,$9::uuid) RETURNING id::text`, publicID, currentActor.tenantID, input.Provider, input.Currency, versionNo, input.SourceRef, input.ChangeSummary, input.EffectiveFrom.UTC(), currentActor.userID).Scan(&internalID); queryErr != nil {
|
||||
return idempotency.Result{}, queryErr
|
||||
}
|
||||
for _, rate := range input.Rates {
|
||||
if _, queryErr := tx.Exec(operationContext, `INSERT INTO billing_price_rate(price_book_id,usage_category,metric_code,unit,unit_price_micros) VALUES($1::uuid,$2,$3,$4,$5)`, internalID, rate.UsageCategory, rate.MetricCode, rate.Unit, rate.UnitPriceMicros); queryErr != nil {
|
||||
return idempotency.Result{}, queryErr
|
||||
}
|
||||
}
|
||||
if queryErr := s.audit.Record(operationContext, tx, audit.Event{TenantID: ¤tActor.tenantID, ActorType: "user", ActorPublicID: ¤tActor.userPublicID, Action: "billing.price_book.create", ResourceType: "billing_price_book", ResourceID: &internalID, RequestID: requestID, Purpose: input.ChangeSummary, Result: "success", Detail: map[string]any{"provider": input.Provider, "currency": input.Currency, "version_no": versionNo, "rate_count": len(input.Rates)}}); queryErr != nil {
|
||||
return idempotency.Result{}, queryErr
|
||||
}
|
||||
item, queryErr := getPriceBookTx(operationContext, tx, currentActor.tenantID, publicID)
|
||||
if queryErr != nil {
|
||||
return idempotency.Result{}, queryErr
|
||||
}
|
||||
body, _ := json.Marshal(item)
|
||||
return idempotency.Result{StatusCode: 201, Body: body, ResourceID: &internalID}, nil
|
||||
})
|
||||
if err != nil {
|
||||
return PriceBook{}, false, err
|
||||
}
|
||||
var item PriceBook
|
||||
if err = json.Unmarshal(result.Body, &item); err != nil {
|
||||
return PriceBook{}, false, err
|
||||
}
|
||||
return item, !result.Replayed, nil
|
||||
}
|
||||
|
||||
func (s *Service) PublishPriceBook(ctx context.Context, principal identity.Principal, tenantPublicID, publicID, requestID, reason string, expectedVersion int64) (PriceBook, error) {
|
||||
reason = strings.TrimSpace(reason)
|
||||
if reason == "" || len([]rune(reason)) > 500 {
|
||||
return PriceBook{}, ErrInvalid
|
||||
}
|
||||
currentActor, err := s.resolveActor(ctx, principal, tenantPublicID, "ops_approver", "ops_admin")
|
||||
if err != nil {
|
||||
return PriceBook{}, err
|
||||
}
|
||||
tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{})
|
||||
if err != nil {
|
||||
return PriceBook{}, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(context.Background()) }()
|
||||
var internalID, status, creatorID, provider, currency string
|
||||
var rowVersion int64
|
||||
var effectiveFrom time.Time
|
||||
err = tx.QueryRow(ctx, `SELECT id::text,status,created_by::text,provider,currency,row_version,effective_from FROM billing_price_book WHERE tenant_id=$1::uuid AND public_id=$2::uuid FOR UPDATE`, currentActor.tenantID, publicID).Scan(&internalID, &status, &creatorID, &provider, ¤cy, &rowVersion, &effectiveFrom)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return PriceBook{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return PriceBook{}, err
|
||||
}
|
||||
if rowVersion != expectedVersion {
|
||||
return PriceBook{}, ErrVersionConflict
|
||||
}
|
||||
if status != "DRAFT" {
|
||||
return PriceBook{}, ErrStateConflict
|
||||
}
|
||||
if creatorID == currentActor.userID {
|
||||
return PriceBook{}, ErrSelfApproval
|
||||
}
|
||||
if effectiveFrom.After(time.Now().UTC().Add(5 * time.Minute)) {
|
||||
return PriceBook{}, ErrStateConflict
|
||||
}
|
||||
var incompatibleCurrency bool
|
||||
if err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM billing_price_book WHERE tenant_id=$1::uuid AND provider<>$2 AND status='PUBLISHED' AND currency<>$3) OR EXISTS(SELECT 1 FROM usage_quota_policy WHERE tenant_id=$1::uuid AND status='ACTIVE' AND currency<>$3)`, currentActor.tenantID, provider, currency).Scan(&incompatibleCurrency); err != nil {
|
||||
return PriceBook{}, err
|
||||
}
|
||||
if incompatibleCurrency {
|
||||
return PriceBook{}, ErrStateConflict
|
||||
}
|
||||
if _, err = tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1,0))`, currentActor.tenantID+":"+provider); err != nil {
|
||||
return PriceBook{}, err
|
||||
}
|
||||
if _, err = tx.Exec(ctx, `UPDATE billing_price_book SET status='RETIRED',retired_at=now(),row_version=row_version+1,updated_at=now() WHERE tenant_id=$1::uuid AND provider=$2 AND status='PUBLISHED'`, currentActor.tenantID, provider); err != nil {
|
||||
return PriceBook{}, err
|
||||
}
|
||||
if _, err = tx.Exec(ctx, `UPDATE billing_price_book SET status='PUBLISHED',published_by=$2::uuid,published_at=now(),row_version=row_version+1,updated_at=now() WHERE id=$1::uuid`, internalID, currentActor.userID); err != nil {
|
||||
return PriceBook{}, err
|
||||
}
|
||||
if err = s.audit.Record(ctx, tx, audit.Event{TenantID: ¤tActor.tenantID, ActorType: "user", ActorPublicID: ¤tActor.userPublicID, Action: "billing.price_book.publish", ResourceType: "billing_price_book", ResourceID: &internalID, RequestID: requestID, Purpose: reason, Result: "success", Detail: map[string]any{"provider": provider, "currency": currency}}); err != nil {
|
||||
return PriceBook{}, err
|
||||
}
|
||||
item, err := getPriceBookTx(ctx, tx, currentActor.tenantID, publicID)
|
||||
if err == nil {
|
||||
err = tx.Commit(ctx)
|
||||
}
|
||||
return item, err
|
||||
}
|
||||
|
||||
func (s *Service) ListPriceBooks(ctx context.Context, principal identity.Principal, tenantPublicID string) ([]PriceBook, error) {
|
||||
currentActor, err := s.resolveActor(ctx, principal, tenantPublicID, "ops_viewer", "ops_operator", "ops_approver", "ops_admin")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `SELECT public_id::text FROM billing_price_book WHERE tenant_id=$1::uuid ORDER BY provider,currency,version_no DESC LIMIT 500`, currentActor.tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
ids := []string{}
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err = rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
if err = rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]PriceBook, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
item, queryErr := getPriceBookPool(ctx, s.pool, currentActor.tenantID, id)
|
||||
if queryErr != nil {
|
||||
return nil, queryErr
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *Service) CreateQuotaPolicy(ctx context.Context, principal identity.Principal, tenantPublicID, key, requestID string, input QuotaInput) (QuotaPolicy, bool, error) {
|
||||
normalizeQuotaInput(&input)
|
||||
if err := validateQuotaInput(input); err != nil {
|
||||
return QuotaPolicy{}, false, err
|
||||
}
|
||||
currentActor, err := s.resolveActor(ctx, principal, tenantPublicID, "ops_approver", "ops_admin")
|
||||
if err != nil {
|
||||
return QuotaPolicy{}, false, err
|
||||
}
|
||||
requestBody, _ := json.Marshal(input)
|
||||
result, err := s.idem.Execute(ctx, idempotency.Request{TenantID: ¤tActor.tenantID, ActorKey: "ops:" + currentActor.userPublicID, Scope: "POST /api/v1/ops/quota-policies", Key: key, RequestHash: idempotency.Hash(requestBody), ExpiresAfter: 24 * time.Hour}, func(operationContext context.Context, tx pgx.Tx) (idempotency.Result, error) {
|
||||
var incompatiblePriceCurrency bool
|
||||
if queryErr := tx.QueryRow(operationContext, `SELECT EXISTS(SELECT 1 FROM billing_price_book WHERE tenant_id=$1::uuid AND status='PUBLISHED' AND currency<>$2)`, currentActor.tenantID, input.Currency).Scan(&incompatiblePriceCurrency); queryErr != nil {
|
||||
return idempotency.Result{}, queryErr
|
||||
}
|
||||
if incompatiblePriceCurrency {
|
||||
return idempotency.Result{}, ErrInvalid
|
||||
}
|
||||
var deviceID *string
|
||||
if input.ScopeType == "DEVICE" {
|
||||
var id string
|
||||
queryErr := tx.QueryRow(operationContext, `SELECT id::text FROM device WHERE tenant_id=$1::uuid AND public_id=$2::uuid AND status='registered'`, currentActor.tenantID, input.ScopeID).Scan(&id)
|
||||
if errors.Is(queryErr, pgx.ErrNoRows) {
|
||||
return idempotency.Result{}, ErrAccessDenied
|
||||
}
|
||||
if queryErr != nil {
|
||||
return idempotency.Result{}, queryErr
|
||||
}
|
||||
deviceID = &id
|
||||
var tenantTimezone, tenantCurrency string
|
||||
queryErr = tx.QueryRow(operationContext, `SELECT billing_timezone,currency FROM usage_quota_policy WHERE tenant_id=$1::uuid AND scope_type='TENANT' AND status='ACTIVE'`, currentActor.tenantID).Scan(&tenantTimezone, &tenantCurrency)
|
||||
if queryErr != nil && !errors.Is(queryErr, pgx.ErrNoRows) {
|
||||
return idempotency.Result{}, queryErr
|
||||
}
|
||||
if queryErr == nil && (tenantTimezone != input.BillingTimezone || tenantCurrency != input.Currency) {
|
||||
return idempotency.Result{}, ErrInvalid
|
||||
}
|
||||
} else {
|
||||
var incompatibleDevicePolicy bool
|
||||
queryErr := tx.QueryRow(operationContext, `SELECT EXISTS(SELECT 1 FROM usage_quota_policy WHERE tenant_id=$1::uuid AND scope_type='DEVICE' AND status='ACTIVE' AND (billing_timezone<>$2 OR currency<>$3))`, currentActor.tenantID, input.BillingTimezone, input.Currency).Scan(&incompatibleDevicePolicy)
|
||||
if queryErr != nil {
|
||||
return idempotency.Result{}, queryErr
|
||||
}
|
||||
if incompatibleDevicePolicy {
|
||||
return idempotency.Result{}, ErrInvalid
|
||||
}
|
||||
}
|
||||
lockKey := currentActor.tenantID + ":quota:" + input.ScopeType + ":" + input.ScopeID
|
||||
if _, queryErr := tx.Exec(operationContext, `SELECT pg_advisory_xact_lock(hashtextextended($1,0))`, lockKey); queryErr != nil {
|
||||
return idempotency.Result{}, queryErr
|
||||
}
|
||||
var versionNo int
|
||||
if queryErr := tx.QueryRow(operationContext, `SELECT COALESCE(MAX(version_no),0)+1 FROM usage_quota_policy WHERE tenant_id=$1::uuid AND scope_type=$2 AND (($2='TENANT') OR device_id=$3::uuid)`, currentActor.tenantID, input.ScopeType, deviceID).Scan(&versionNo); queryErr != nil {
|
||||
return idempotency.Result{}, queryErr
|
||||
}
|
||||
if _, queryErr := tx.Exec(operationContext, `UPDATE usage_quota_policy SET status='RETIRED',retired_at=now(),row_version=row_version+1,updated_at=now() WHERE tenant_id=$1::uuid AND scope_type=$2 AND status='ACTIVE' AND (($2='TENANT') OR device_id=$3::uuid)`, currentActor.tenantID, input.ScopeType, deviceID); queryErr != nil {
|
||||
return idempotency.Result{}, queryErr
|
||||
}
|
||||
publicID := uuid.NewString()
|
||||
var internalID string
|
||||
if queryErr := tx.QueryRow(operationContext, `INSERT INTO usage_quota_policy(public_id,tenant_id,scope_type,device_id,version_no,billing_timezone,currency,daily_limit_micros,monthly_limit_micros,max_concurrent_calls,max_call_duration_sec,in_progress_action,change_summary,created_by) VALUES($1::uuid,$2::uuid,$3,$4::uuid,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14::uuid) RETURNING id::text`, publicID, currentActor.tenantID, input.ScopeType, deviceID, versionNo, input.BillingTimezone, input.Currency, input.DailyLimitMicros, input.MonthlyLimitMicros, input.MaxConcurrentCalls, input.MaxCallDurationSec, input.InProgressAction, input.ChangeSummary, currentActor.userID).Scan(&internalID); queryErr != nil {
|
||||
return idempotency.Result{}, queryErr
|
||||
}
|
||||
if queryErr := s.audit.Record(operationContext, tx, audit.Event{TenantID: ¤tActor.tenantID, ActorType: "user", ActorPublicID: ¤tActor.userPublicID, Action: "billing.quota_policy.activate", ResourceType: "usage_quota_policy", ResourceID: &internalID, RequestID: requestID, Purpose: input.ChangeSummary, Result: "success", Detail: map[string]any{"scope_type": input.ScopeType, "scope_id": input.ScopeID, "version_no": versionNo}}); queryErr != nil {
|
||||
return idempotency.Result{}, queryErr
|
||||
}
|
||||
item, queryErr := getQuotaTx(operationContext, tx, currentActor.tenantID, publicID)
|
||||
if queryErr != nil {
|
||||
return idempotency.Result{}, queryErr
|
||||
}
|
||||
body, _ := json.Marshal(item)
|
||||
return idempotency.Result{StatusCode: 201, Body: body, ResourceID: &internalID}, nil
|
||||
})
|
||||
if err != nil {
|
||||
return QuotaPolicy{}, false, err
|
||||
}
|
||||
var item QuotaPolicy
|
||||
if err = json.Unmarshal(result.Body, &item); err != nil {
|
||||
return QuotaPolicy{}, false, err
|
||||
}
|
||||
return item, !result.Replayed, nil
|
||||
}
|
||||
|
||||
func (s *Service) ListQuotaPolicies(ctx context.Context, principal identity.Principal, tenantPublicID string) ([]QuotaPolicy, error) {
|
||||
currentActor, err := s.resolveActor(ctx, principal, tenantPublicID, "ops_viewer", "ops_operator", "ops_approver", "ops_admin")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, quotaSelect+` WHERE qp.tenant_id=$1::uuid ORDER BY CASE qp.status WHEN 'ACTIVE' THEN 1 ELSE 2 END,qp.scope_type,qp.version_no DESC LIMIT 1000`, currentActor.tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []QuotaPolicy{}
|
||||
for rows.Next() {
|
||||
item, scanErr := scanQuota(rows)
|
||||
if scanErr != nil {
|
||||
return nil, scanErr
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Service) resolveActor(ctx context.Context, principal identity.Principal, tenantPublicID string, roles ...string) (actor, error) {
|
||||
if _, err := uuid.Parse(tenantPublicID); err != nil {
|
||||
return actor{}, ErrInvalid
|
||||
}
|
||||
var result actor
|
||||
err := s.pool.QueryRow(ctx, `SELECT t.id::text,t.public_id::text,ua.id::text,ua.public_id::text,ua.platform_role FROM user_account ua JOIN tenant t ON t.id=ua.tenant_id AND t.status='active' WHERE t.public_id=$1::uuid AND ua.oidc_issuer=$2 AND ua.oidc_subject=$3 AND ua.status='active'`, tenantPublicID, principal.Issuer, principal.Subject).Scan(&result.tenantID, &result.tenantPublicID, &result.userID, &result.userPublicID, &result.role)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return actor{}, ErrAccessDenied
|
||||
}
|
||||
if err != nil {
|
||||
return actor{}, err
|
||||
}
|
||||
for _, role := range roles {
|
||||
if result.role == role {
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
return actor{}, ErrAccessDenied
|
||||
}
|
||||
|
||||
func normalizePriceInput(input *CreatePriceBookInput) {
|
||||
input.Provider = strings.ToLower(strings.TrimSpace(input.Provider))
|
||||
input.Currency = strings.ToUpper(strings.TrimSpace(input.Currency))
|
||||
input.SourceRef = strings.TrimSpace(input.SourceRef)
|
||||
input.ChangeSummary = strings.TrimSpace(input.ChangeSummary)
|
||||
for index := range input.Rates {
|
||||
input.Rates[index].UsageCategory = strings.ToUpper(strings.TrimSpace(input.Rates[index].UsageCategory))
|
||||
input.Rates[index].MetricCode = strings.ToLower(strings.TrimSpace(input.Rates[index].MetricCode))
|
||||
input.Rates[index].Unit = strings.ToLower(strings.TrimSpace(input.Rates[index].Unit))
|
||||
}
|
||||
}
|
||||
|
||||
func validatePriceInput(input CreatePriceBookInput) error {
|
||||
if _, err := uuid.Parse(input.TenantID); err != nil {
|
||||
return ErrInvalid
|
||||
}
|
||||
if !regexp.MustCompile(`^[a-z][a-z0-9_.-]{1,39}$`).MatchString(input.Provider) || !regexp.MustCompile(`^[A-Z]{3}$`).MatchString(input.Currency) || input.SourceRef == "" || len([]rune(input.SourceRef)) > 300 || input.ChangeSummary == "" || len([]rune(input.ChangeSummary)) > 500 || input.EffectiveFrom.IsZero() || len(input.Rates) == 0 || len(input.Rates) > 100 {
|
||||
return ErrInvalid
|
||||
}
|
||||
seen := map[string]struct{}{}
|
||||
for _, rate := range input.Rates {
|
||||
if !validCategory(rate.UsageCategory) || !codePattern.MatchString(rate.MetricCode) || !regexp.MustCompile(`^[a-z][a-z0-9_.-]{1,39}$`).MatchString(rate.Unit) || rate.UnitPriceMicros < 0 {
|
||||
return ErrInvalid
|
||||
}
|
||||
key := rate.UsageCategory + ":" + rate.MetricCode + ":" + rate.Unit
|
||||
if _, exists := seen[key]; exists {
|
||||
return ErrInvalid
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeQuotaInput(input *QuotaInput) {
|
||||
input.ScopeType = strings.ToUpper(strings.TrimSpace(input.ScopeType))
|
||||
input.BillingTimezone = strings.TrimSpace(input.BillingTimezone)
|
||||
input.Currency = strings.ToUpper(strings.TrimSpace(input.Currency))
|
||||
input.InProgressAction = strings.ToUpper(strings.TrimSpace(input.InProgressAction))
|
||||
input.ChangeSummary = strings.TrimSpace(input.ChangeSummary)
|
||||
}
|
||||
|
||||
func validateQuotaInput(input QuotaInput) error {
|
||||
if input.ScopeType != "TENANT" && input.ScopeType != "DEVICE" {
|
||||
return ErrInvalid
|
||||
}
|
||||
if input.ScopeType == "TENANT" && strings.TrimSpace(input.ScopeID) != "" {
|
||||
return ErrInvalid
|
||||
}
|
||||
if input.ScopeType == "DEVICE" {
|
||||
if _, err := uuid.Parse(input.ScopeID); err != nil {
|
||||
return ErrInvalid
|
||||
}
|
||||
}
|
||||
if _, err := time.LoadLocation(input.BillingTimezone); err != nil {
|
||||
return ErrInvalid
|
||||
}
|
||||
if !regexp.MustCompile(`^[A-Z]{3}$`).MatchString(input.Currency) || input.DailyLimitMicros <= 0 || input.MonthlyLimitMicros < input.DailyLimitMicros || input.MaxConcurrentCalls < 1 || input.MaxConcurrentCalls > 100000 || input.MaxCallDurationSec < 60 || input.MaxCallDurationSec > 86400 || (input.InProgressAction != "STOP_IMMEDIATELY" && input.InProgressAction != "COMPLETE_CURRENT_TURN") || input.ChangeSummary == "" || len([]rune(input.ChangeSummary)) > 500 {
|
||||
return ErrInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validCategory(value string) bool {
|
||||
switch value {
|
||||
case "RTC", "END_TO_END_VOICE", "ASR", "LLM", "TTS", "STORAGE", "POST_PROCESSING":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type rowScanner interface{ Scan(...any) error }
|
||||
|
||||
const priceBookSelect = `SELECT pb.public_id::text,t.public_id::text,pb.provider,pb.currency,pb.version_no,pb.status,pb.source_ref,pb.change_summary,pb.effective_from,creator.public_id::text,publisher.public_id::text,pb.published_at,pb.retired_at,pb.row_version,pb.created_at,pb.updated_at FROM billing_price_book pb JOIN tenant t ON t.id=pb.tenant_id JOIN user_account creator ON creator.id=pb.created_by LEFT JOIN user_account publisher ON publisher.id=pb.published_by`
|
||||
|
||||
func scanPriceBook(row rowScanner) (PriceBook, error) {
|
||||
var item PriceBook
|
||||
err := row.Scan(&item.ID, &item.TenantID, &item.Provider, &item.Currency, &item.VersionNo, &item.Status, &item.SourceRef, &item.ChangeSummary, &item.EffectiveFrom, &item.CreatedBy, &item.PublishedBy, &item.PublishedAt, &item.RetiredAt, &item.RowVersion, &item.CreatedAt, &item.UpdatedAt)
|
||||
return item, err
|
||||
}
|
||||
|
||||
func getPriceBookTx(ctx context.Context, tx pgx.Tx, tenantID, publicID string) (PriceBook, error) {
|
||||
item, err := scanPriceBook(tx.QueryRow(ctx, priceBookSelect+` WHERE pb.tenant_id=$1::uuid AND pb.public_id=$2::uuid`, tenantID, publicID))
|
||||
if err != nil {
|
||||
return PriceBook{}, err
|
||||
}
|
||||
rows, err := tx.Query(ctx, `SELECT id::text,usage_category,metric_code,unit,unit_price_micros FROM billing_price_rate WHERE price_book_id=(SELECT id FROM billing_price_book WHERE tenant_id=$1::uuid AND public_id=$2::uuid) ORDER BY usage_category,metric_code,unit`, tenantID, publicID)
|
||||
if err != nil {
|
||||
return PriceBook{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
item.Rates = []PriceRate{}
|
||||
for rows.Next() {
|
||||
var rate PriceRate
|
||||
if err = rows.Scan(&rate.ID, &rate.UsageCategory, &rate.MetricCode, &rate.Unit, &rate.UnitPriceMicros); err != nil {
|
||||
return PriceBook{}, err
|
||||
}
|
||||
item.Rates = append(item.Rates, rate)
|
||||
}
|
||||
return item, rows.Err()
|
||||
}
|
||||
|
||||
func getPriceBookPool(ctx context.Context, pool *pgxpool.Pool, tenantID, publicID string) (PriceBook, error) {
|
||||
item, err := scanPriceBook(pool.QueryRow(ctx, priceBookSelect+` WHERE pb.tenant_id=$1::uuid AND pb.public_id=$2::uuid`, tenantID, publicID))
|
||||
if err != nil {
|
||||
return PriceBook{}, err
|
||||
}
|
||||
rows, err := pool.Query(ctx, `SELECT id::text,usage_category,metric_code,unit,unit_price_micros FROM billing_price_rate WHERE price_book_id=(SELECT id FROM billing_price_book WHERE tenant_id=$1::uuid AND public_id=$2::uuid) ORDER BY usage_category,metric_code,unit`, tenantID, publicID)
|
||||
if err != nil {
|
||||
return PriceBook{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
item.Rates = []PriceRate{}
|
||||
for rows.Next() {
|
||||
var rate PriceRate
|
||||
if err = rows.Scan(&rate.ID, &rate.UsageCategory, &rate.MetricCode, &rate.Unit, &rate.UnitPriceMicros); err != nil {
|
||||
return PriceBook{}, err
|
||||
}
|
||||
item.Rates = append(item.Rates, rate)
|
||||
}
|
||||
return item, rows.Err()
|
||||
}
|
||||
|
||||
const quotaSelect = `SELECT qp.public_id::text,t.public_id::text,qp.scope_type,d.public_id::text,qp.version_no,qp.status,qp.billing_timezone,qp.currency,qp.daily_limit_micros,qp.monthly_limit_micros,qp.max_concurrent_calls,qp.max_call_duration_sec,qp.in_progress_action,qp.change_summary,ua.public_id::text,qp.retired_at,qp.row_version,qp.created_at,qp.updated_at FROM usage_quota_policy qp JOIN tenant t ON t.id=qp.tenant_id JOIN user_account ua ON ua.id=qp.created_by LEFT JOIN device d ON d.id=qp.device_id`
|
||||
|
||||
func scanQuota(row rowScanner) (QuotaPolicy, error) {
|
||||
var item QuotaPolicy
|
||||
err := row.Scan(&item.ID, &item.TenantID, &item.ScopeType, &item.ScopeID, &item.VersionNo, &item.Status, &item.BillingTimezone, &item.Currency, &item.DailyLimitMicros, &item.MonthlyLimitMicros, &item.MaxConcurrentCalls, &item.MaxCallDurationSec, &item.InProgressAction, &item.ChangeSummary, &item.CreatedBy, &item.RetiredAt, &item.RowVersion, &item.CreatedAt, &item.UpdatedAt)
|
||||
return item, err
|
||||
}
|
||||
func getQuotaTx(ctx context.Context, tx pgx.Tx, tenantID, publicID string) (QuotaPolicy, error) {
|
||||
item, err := scanQuota(tx.QueryRow(ctx, quotaSelect+` WHERE qp.tenant_id=$1::uuid AND qp.public_id=$2::uuid`, tenantID, publicID))
|
||||
return item, err
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package billing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"data-terminal-cloud/internal/audit"
|
||||
"data-terminal-cloud/internal/database"
|
||||
"data-terminal-cloud/internal/deviceauth"
|
||||
"data-terminal-cloud/internal/identity"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
const (
|
||||
billingTestIssuer = "http://localhost:58180/realms/voice-local"
|
||||
billingTenantPublicID = "10000000-0000-0000-0000-000000000010"
|
||||
billingOtherTenantID = "20000000-0000-0000-0000-000000000010"
|
||||
billingAdminSubject = "00000000-0000-0000-0000-000000000101"
|
||||
billingApproverSubject = "00000000-0000-0000-0000-000000000102"
|
||||
billingDevicePublicID = "10000000-0000-0000-0000-000000000511"
|
||||
billingTestSource = "integration://billing-service"
|
||||
billingTestChangePrefix = "费用集成测试"
|
||||
)
|
||||
|
||||
func TestServicePriceAndQuotaLifecycle(t *testing.T) {
|
||||
databaseURL := os.Getenv("TEST_DATABASE_URL")
|
||||
if databaseURL == "" {
|
||||
t.Skip("TEST_DATABASE_URL is not set")
|
||||
}
|
||||
ctx := context.Background()
|
||||
pool, err := database.Open(ctx, databaseURL, "billing-service-test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer pool.Close()
|
||||
cleanupBillingFixture(t, ctx, pool)
|
||||
defer cleanupBillingFixture(t, context.Background(), pool)
|
||||
|
||||
service, err := NewService(pool, audit.NewRepository())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
admin := identity.Principal{Issuer: billingTestIssuer, Subject: billingAdminSubject}
|
||||
approver := identity.Principal{Issuer: billingTestIssuer, Subject: billingApproverSubject}
|
||||
priceInput := CreatePriceBookInput{
|
||||
TenantID: billingTenantPublicID, Provider: " MOCK ", Currency: " cny ",
|
||||
SourceRef: billingTestSource, ChangeSummary: billingTestChangePrefix + "价格版本",
|
||||
EffectiveFrom: time.Now().UTC().Add(-time.Hour),
|
||||
Rates: []RateInput{
|
||||
{UsageCategory: " llm ", MetricCode: " INPUT_TOKENS ", Unit: " TOKEN ", UnitPriceMicros: 2},
|
||||
{UsageCategory: " tts ", MetricCode: " AUDIO_SECONDS ", Unit: " SECOND ", UnitPriceMicros: 5},
|
||||
},
|
||||
}
|
||||
createdPrice, created, err := service.CreatePriceBook(ctx, admin, "billing-integration-price-1", "billing-price-create", priceInput)
|
||||
if err != nil || !created || createdPrice.Status != "DRAFT" || createdPrice.Provider != "mock" || createdPrice.Currency != "CNY" || len(createdPrice.Rates) != 2 {
|
||||
t.Fatalf("created price=%#v created=%v err=%v", createdPrice, created, err)
|
||||
}
|
||||
replayedPrice, created, err := service.CreatePriceBook(ctx, admin, "billing-integration-price-1", "billing-price-replay", priceInput)
|
||||
if err != nil || created || replayedPrice.ID != createdPrice.ID {
|
||||
t.Fatalf("replayed price=%#v created=%v err=%v", replayedPrice, created, err)
|
||||
}
|
||||
if _, err := service.PublishPriceBook(ctx, admin, billingTenantPublicID, createdPrice.ID, "billing-price-self", "本人不得审批", createdPrice.RowVersion); !errors.Is(err, ErrSelfApproval) {
|
||||
t.Fatalf("self approval error=%v", err)
|
||||
}
|
||||
publishedPrice, err := service.PublishPriceBook(ctx, approver, billingTenantPublicID, createdPrice.ID, "billing-price-publish", "另一运营人员复核发布", createdPrice.RowVersion)
|
||||
if err != nil || publishedPrice.Status != "PUBLISHED" || publishedPrice.PublishedBy == nil || publishedPrice.RowVersion != createdPrice.RowVersion+1 {
|
||||
t.Fatalf("published price=%#v err=%v", publishedPrice, err)
|
||||
}
|
||||
if _, err := service.PublishPriceBook(ctx, approver, billingTenantPublicID, createdPrice.ID, "billing-price-repeat", "重复发布应拒绝", publishedPrice.RowVersion); !errors.Is(err, ErrStateConflict) {
|
||||
t.Fatalf("repeat publish error=%v", err)
|
||||
}
|
||||
prices, err := service.ListPriceBooks(ctx, admin, billingTenantPublicID)
|
||||
if err != nil || len(prices) != 1 || prices[0].ID != createdPrice.ID {
|
||||
t.Fatalf("prices=%#v err=%v", prices, err)
|
||||
}
|
||||
|
||||
tenantQuotaInput := QuotaInput{
|
||||
ScopeType: " tenant ", BillingTimezone: "Asia/Shanghai", Currency: " cny ",
|
||||
DailyLimitMicros: 10_000_000, MonthlyLimitMicros: 100_000_000,
|
||||
MaxConcurrentCalls: 10, MaxCallDurationSec: 3600,
|
||||
InProgressAction: " complete_current_turn ", ChangeSummary: billingTestChangePrefix + "客户组织额度V1",
|
||||
}
|
||||
tenantQuota, created, err := service.CreateQuotaPolicy(ctx, approver, billingTenantPublicID, "billing-integration-quota-1", "billing-quota-create", tenantQuotaInput)
|
||||
if err != nil || !created || tenantQuota.Status != "ACTIVE" || tenantQuota.ScopeType != "TENANT" || tenantQuota.VersionNo != 1 {
|
||||
t.Fatalf("tenant quota=%#v created=%v err=%v", tenantQuota, created, err)
|
||||
}
|
||||
replayedQuota, created, err := service.CreateQuotaPolicy(ctx, approver, billingTenantPublicID, "billing-integration-quota-1", "billing-quota-replay", tenantQuotaInput)
|
||||
if err != nil || created || replayedQuota.ID != tenantQuota.ID {
|
||||
t.Fatalf("replayed quota=%#v created=%v err=%v", replayedQuota, created, err)
|
||||
}
|
||||
deviceQuotaInput := tenantQuotaInput
|
||||
deviceQuotaInput.ScopeType = "DEVICE"
|
||||
deviceQuotaInput.ScopeID = billingDevicePublicID
|
||||
deviceQuotaInput.DailyLimitMicros = 5_000_000
|
||||
deviceQuotaInput.MonthlyLimitMicros = 50_000_000
|
||||
deviceQuotaInput.MaxCallDurationSec = 1800
|
||||
deviceQuotaInput.InProgressAction = "STOP_IMMEDIATELY"
|
||||
deviceQuotaInput.ChangeSummary = billingTestChangePrefix + "设备额度V1"
|
||||
deviceQuota, created, err := service.CreateQuotaPolicy(ctx, approver, billingTenantPublicID, "billing-integration-quota-device", "billing-device-quota-create", deviceQuotaInput)
|
||||
if err != nil || !created || deviceQuota.ScopeID == nil || *deviceQuota.ScopeID != billingDevicePublicID {
|
||||
t.Fatalf("device quota=%#v created=%v err=%v", deviceQuota, created, err)
|
||||
}
|
||||
tenantQuotaInput.DailyLimitMicros = 20_000_000
|
||||
tenantQuotaInput.MonthlyLimitMicros = 200_000_000
|
||||
tenantQuotaInput.ChangeSummary = billingTestChangePrefix + "客户组织额度V2"
|
||||
replacementQuota, created, err := service.CreateQuotaPolicy(ctx, approver, billingTenantPublicID, "billing-integration-quota-2", "billing-quota-replace", tenantQuotaInput)
|
||||
if err != nil || !created || replacementQuota.VersionNo != 2 || replacementQuota.Status != "ACTIVE" {
|
||||
t.Fatalf("replacement quota=%#v created=%v err=%v", replacementQuota, created, err)
|
||||
}
|
||||
quotas, err := service.ListQuotaPolicies(ctx, admin, billingTenantPublicID)
|
||||
if err != nil || len(quotas) != 3 {
|
||||
t.Fatalf("quotas=%#v err=%v", quotas, err)
|
||||
}
|
||||
if _, err := service.ListQuotaPolicies(ctx, approver, billingOtherTenantID); !errors.Is(err, ErrAccessDenied) {
|
||||
t.Fatalf("cross-tenant quota access error=%v", err)
|
||||
}
|
||||
tx, err := pool.Begin(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deviceIdentity := deviceauth.Identity{
|
||||
ID: "10000000-0000-0000-0000-000000000501", TenantID: "10000000-0000-0000-0000-000000000001",
|
||||
}
|
||||
budget, err := CheckQuotaTx(ctx, tx, deviceIdentity, time.Now().UTC())
|
||||
if err != nil || budget == nil || budget.TenantPolicyID == nil || budget.DevicePolicyID == nil || budget.DailyLimitMicros != 5_000_000 || budget.MaxCallDurationSec != 1800 || budget.InProgressAction != "STOP_IMMEDIATELY" {
|
||||
_ = tx.Rollback(ctx)
|
||||
t.Fatalf("effective budget=%#v err=%v", budget, err)
|
||||
}
|
||||
if err := FreezeBudgetTx(ctx, tx, "", deviceIdentity.TenantID, nil); err != nil {
|
||||
_ = tx.Rollback(ctx)
|
||||
t.Fatal(err)
|
||||
}
|
||||
noPolicy, err := CheckQuotaTx(ctx, tx, deviceauth.Identity{ID: "20000000-0000-0000-0000-000000000501", TenantID: "20000000-0000-0000-0000-000000000001"}, time.Now().UTC())
|
||||
if err != nil || noPolicy != nil {
|
||||
_ = tx.Rollback(ctx)
|
||||
t.Fatalf("no-policy budget=%#v err=%v", noPolicy, err)
|
||||
}
|
||||
if err := tx.Rollback(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
summary, err := service.CostSummary(ctx, admin, billingTenantPublicID, time.Now().UTC().Add(-24*time.Hour), time.Now().UTC())
|
||||
if err != nil || summary.CostMicros != 0 || len(summary.Lines) != 0 || summary.Currency != "" {
|
||||
t.Fatalf("empty cost summary=%#v err=%v", summary, err)
|
||||
}
|
||||
}
|
||||
|
||||
func cleanupBillingFixture(t *testing.T, ctx context.Context, pool *pgxpool.Pool) {
|
||||
t.Helper()
|
||||
statements := []struct {
|
||||
query string
|
||||
args []any
|
||||
}{
|
||||
{`DELETE FROM idempotency_record WHERE idempotency_key LIKE 'billing-integration-%'`, nil},
|
||||
{`DELETE FROM audit_event WHERE request_id LIKE 'billing-%'`, nil},
|
||||
{`DELETE FROM usage_quota_policy WHERE change_summary LIKE $1`, []any{billingTestChangePrefix + "%"}},
|
||||
{`DELETE FROM billing_price_book WHERE source_ref=$1`, []any{billingTestSource}},
|
||||
}
|
||||
for _, statement := range statements {
|
||||
if _, err := pool.Exec(ctx, statement.query, statement.args...); err != nil {
|
||||
t.Fatalf("cleanup billing fixture: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package billing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestValidatePriceInput(t *testing.T) {
|
||||
input := CreatePriceBookInput{
|
||||
TenantID: "10000000-0000-0000-0000-000000000010", Provider: "mock", Currency: "CNY",
|
||||
SourceRef: "contract://mock/2026-v1", ChangeSummary: "本地回归单价", EffectiveFrom: time.Now().UTC(),
|
||||
Rates: []RateInput{{UsageCategory: "LLM", MetricCode: "input_tokens", Unit: "token", UnitPriceMicros: 2}},
|
||||
}
|
||||
if err := validatePriceInput(input); err != nil {
|
||||
t.Fatalf("valid price input rejected: %v", err)
|
||||
}
|
||||
input.Rates = append(input.Rates, input.Rates[0])
|
||||
if err := validatePriceInput(input); !errors.Is(err, ErrInvalid) {
|
||||
t.Fatalf("duplicate rate error = %v, want ErrInvalid", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateQuotaInput(t *testing.T) {
|
||||
valid := QuotaInput{ScopeType: "TENANT", BillingTimezone: "Asia/Shanghai", Currency: "CNY", DailyLimitMicros: 10_000_000, MonthlyLimitMicros: 100_000_000, MaxConcurrentCalls: 10, MaxCallDurationSec: 3600, InProgressAction: "COMPLETE_CURRENT_TURN", ChangeSummary: "家庭试点额度"}
|
||||
if err := validateQuotaInput(valid); err != nil {
|
||||
t.Fatalf("valid quota rejected: %v", err)
|
||||
}
|
||||
for name, mutate := range map[string]func(*QuotaInput){
|
||||
"tenant scope id": func(item *QuotaInput) { item.ScopeID = "10000000-0000-0000-0000-000000000511" },
|
||||
"monthly below daily": func(item *QuotaInput) { item.MonthlyLimitMicros = item.DailyLimitMicros - 1 },
|
||||
"invalid timezone": func(item *QuotaInput) { item.BillingTimezone = "China/Unknown" },
|
||||
"short duration": func(item *QuotaInput) { item.MaxCallDurationSec = 59 },
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
item := valid
|
||||
mutate(&item)
|
||||
if err := validateQuotaInput(item); !errors.Is(err, ErrInvalid) {
|
||||
t.Fatalf("error = %v, want ErrInvalid", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsageQuantityPrecision(t *testing.T) {
|
||||
for _, value := range []string{"0", "1", "1000", "0.000001", "999999999999999999.999999"} {
|
||||
if !quantityPattern.MatchString(value) {
|
||||
t.Fatalf("valid quantity %q rejected", value)
|
||||
}
|
||||
}
|
||||
for _, value := range []string{"-1", "01", "1.0000001", "1e3", "9999999999999999999"} {
|
||||
if quantityPattern.MatchString(value) {
|
||||
t.Fatalf("invalid quantity %q accepted", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordProviderUsageValidation(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
if _, err := RecordProviderUsageTx(ctx, nil, "", "", "", "", "", time.Time{}, nil); !errors.Is(err, ErrInvalid) {
|
||||
t.Fatalf("empty usage error=%v", err)
|
||||
}
|
||||
metrics := []UsageMetricInput{
|
||||
{UsageCategory: " llm ", MetricCode: " INPUT_TOKENS ", Quantity: "12", Unit: " TOKEN "},
|
||||
{UsageCategory: "LLM", MetricCode: "input_tokens", Quantity: "13", Unit: "token"},
|
||||
}
|
||||
if _, err := RecordProviderUsageTx(ctx, nil,
|
||||
"10000000-0000-0000-0000-000000000001", "10000000-0000-0000-0000-000000000901",
|
||||
"10000000-0000-0000-0000-000000000902", "mock", "usage-validation", time.Now().UTC(), metrics,
|
||||
); !errors.Is(err, ErrInvalid) {
|
||||
t.Fatalf("duplicate metric error=%v", err)
|
||||
}
|
||||
metrics = []UsageMetricInput{{UsageCategory: "UNKNOWN", MetricCode: "input_tokens", Quantity: "1", Unit: "token"}}
|
||||
if _, err := RecordProviderUsageTx(ctx, nil,
|
||||
"10000000-0000-0000-0000-000000000001", "10000000-0000-0000-0000-000000000901",
|
||||
"10000000-0000-0000-0000-000000000902", "mock", "usage-validation", time.Now().UTC(), metrics,
|
||||
); !errors.Is(err, ErrInvalid) {
|
||||
t.Fatalf("invalid category error=%v", err)
|
||||
}
|
||||
if _, err := NewService(nil, nil); err == nil {
|
||||
t.Fatal("invalid billing service dependencies were accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
package billing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"data-terminal-cloud/internal/deviceauth"
|
||||
"data-terminal-cloud/internal/identity"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrUsageConflict = errors.New("usage event key was reused with different content")
|
||||
ErrPriceRateNotFound = errors.New("published price rate was not found for usage")
|
||||
ErrQuotaExceeded = errors.New("usage quota was exceeded")
|
||||
)
|
||||
|
||||
var quantityPattern = regexp.MustCompile(`^(0|[1-9][0-9]{0,17})(\.[0-9]{1,6})?$`)
|
||||
|
||||
type UsageMetricInput struct {
|
||||
UsageCategory string `json:"usage_category"`
|
||||
MetricCode string `json:"metric_code"`
|
||||
Quantity string `json:"quantity"`
|
||||
Unit string `json:"unit"`
|
||||
}
|
||||
|
||||
type RecordedUsage struct {
|
||||
UsageEventID string `json:"usage_event_id"`
|
||||
CostLineID string `json:"cost_line_id"`
|
||||
CostMicros int64 `json:"cost_micros"`
|
||||
Currency string `json:"currency"`
|
||||
}
|
||||
|
||||
type CostSummaryLine struct {
|
||||
UsageCategory string `json:"usage_category"`
|
||||
MetricCode string `json:"metric_code"`
|
||||
Quantity string `json:"quantity"`
|
||||
Unit string `json:"unit"`
|
||||
CostMicros int64 `json:"cost_micros"`
|
||||
Currency string `json:"currency"`
|
||||
}
|
||||
|
||||
type CostSummary struct {
|
||||
From time.Time `json:"from"`
|
||||
To time.Time `json:"to"`
|
||||
CostMicros int64 `json:"cost_micros"`
|
||||
Currency string `json:"currency"`
|
||||
Lines []CostSummaryLine `json:"lines"`
|
||||
}
|
||||
|
||||
func RecordProviderUsageTx(ctx context.Context, tx pgx.Tx, tenantID, conversationID, providerEventID, provider, sourceEventKey string, occurredAt time.Time, metrics []UsageMetricInput) ([]RecordedUsage, error) {
|
||||
provider = strings.ToLower(strings.TrimSpace(provider))
|
||||
sourceEventKey = strings.TrimSpace(sourceEventKey)
|
||||
if provider == "" || sourceEventKey == "" || occurredAt.IsZero() || len(metrics) == 0 || len(metrics) > 100 {
|
||||
return nil, ErrInvalid
|
||||
}
|
||||
seen := map[string]struct{}{}
|
||||
for index := range metrics {
|
||||
metrics[index].UsageCategory = strings.ToUpper(strings.TrimSpace(metrics[index].UsageCategory))
|
||||
metrics[index].MetricCode = strings.ToLower(strings.TrimSpace(metrics[index].MetricCode))
|
||||
metrics[index].Quantity = strings.TrimSpace(metrics[index].Quantity)
|
||||
metrics[index].Unit = strings.ToLower(strings.TrimSpace(metrics[index].Unit))
|
||||
key := metrics[index].UsageCategory + ":" + metrics[index].MetricCode + ":" + metrics[index].Unit
|
||||
if !validCategory(metrics[index].UsageCategory) || !codePattern.MatchString(metrics[index].MetricCode) || !regexp.MustCompile(`^[a-z][a-z0-9_.-]{1,39}$`).MatchString(metrics[index].Unit) || !quantityPattern.MatchString(metrics[index].Quantity) {
|
||||
return nil, ErrInvalid
|
||||
}
|
||||
if _, exists := seen[key]; exists {
|
||||
return nil, ErrInvalid
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
}
|
||||
result := make([]RecordedUsage, 0, len(metrics))
|
||||
for _, metric := range metrics {
|
||||
usagePublicID := uuid.NewString()
|
||||
var usageInternalID string
|
||||
tag, err := tx.Exec(ctx, `
|
||||
INSERT INTO conversation_usage_event(public_id,tenant_id,conversation_id,provider_event_id,provider,source_event_key,usage_category,metric_code,quantity,unit,occurred_at)
|
||||
VALUES($1::uuid,$2::uuid,$3::uuid,$4::uuid,$5,$6,$7,$8,$9::numeric,$10,$11)
|
||||
ON CONFLICT(tenant_id,provider,source_event_key,usage_category,metric_code,unit) DO NOTHING`,
|
||||
usagePublicID, tenantID, conversationID, providerEventID, provider, sourceEventKey, metric.UsageCategory, metric.MetricCode, metric.Quantity, metric.Unit, occurredAt.UTC())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
var quantityMatches, occurredAtMatches bool
|
||||
if err = tx.QueryRow(ctx, `SELECT id::text,quantity=$7::numeric,occurred_at=$8 FROM conversation_usage_event WHERE tenant_id=$1::uuid AND provider=$2 AND source_event_key=$3 AND usage_category=$4 AND metric_code=$5 AND unit=$6`, tenantID, provider, sourceEventKey, metric.UsageCategory, metric.MetricCode, metric.Unit, metric.Quantity, occurredAt.UTC()).Scan(&usageInternalID, &quantityMatches, &occurredAtMatches); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !quantityMatches || !occurredAtMatches {
|
||||
return nil, ErrUsageConflict
|
||||
}
|
||||
var recorded RecordedUsage
|
||||
err = tx.QueryRow(ctx, `SELECT cue.public_id::text,ccl.public_id::text,ccl.cost_micros,ccl.currency FROM conversation_usage_event cue JOIN conversation_cost_line ccl ON ccl.usage_event_id=cue.id WHERE cue.id=$1::uuid`, usageInternalID).Scan(&recorded.UsageEventID, &recorded.CostLineID, &recorded.CostMicros, &recorded.Currency)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, recorded)
|
||||
continue
|
||||
}
|
||||
if err = tx.QueryRow(ctx, `SELECT id::text FROM conversation_usage_event WHERE public_id=$1::uuid`, usagePublicID).Scan(&usageInternalID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var priceBookID, priceRateID, currency string
|
||||
var unitPriceMicros int64
|
||||
err = tx.QueryRow(ctx, `
|
||||
SELECT pb.id::text,pr.id::text,pr.unit_price_micros,pb.currency
|
||||
FROM billing_price_book pb JOIN billing_price_rate pr ON pr.price_book_id=pb.id
|
||||
WHERE pb.tenant_id=$1::uuid AND pb.provider=$2 AND pb.status IN ('PUBLISHED','RETIRED')
|
||||
AND pb.published_at IS NOT NULL AND pb.effective_from<=$3
|
||||
AND pr.usage_category=$4 AND pr.metric_code=$5 AND pr.unit=$6
|
||||
ORDER BY pb.effective_from DESC,pb.version_no DESC LIMIT 1`, tenantID, provider, occurredAt.UTC(), metric.UsageCategory, metric.MetricCode, metric.Unit).Scan(&priceBookID, &priceRateID, &unitPriceMicros, ¤cy)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrPriceRateNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
costPublicID := uuid.NewString()
|
||||
var costMicros int64
|
||||
if err = tx.QueryRow(ctx, `INSERT INTO conversation_cost_line(public_id,tenant_id,conversation_id,usage_event_id,price_book_id,price_rate_id,quantity,unit,unit_price_micros,cost_micros,currency) VALUES($1::uuid,$2::uuid,$3::uuid,$4::uuid,$5::uuid,$6::uuid,$7::numeric,$8,$9::bigint,round($7::numeric*$9::numeric)::bigint,$10) RETURNING cost_micros`, costPublicID, tenantID, conversationID, usageInternalID, priceBookID, priceRateID, metric.Quantity, metric.Unit, unitPriceMicros, currency).Scan(&costMicros); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, RecordedUsage{UsageEventID: usagePublicID, CostLineID: costPublicID, CostMicros: costMicros, Currency: currency})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Service) CostSummary(ctx context.Context, principal identity.Principal, tenantPublicID string, from, to time.Time) (CostSummary, error) {
|
||||
currentActor, err := s.resolveActor(ctx, principal, tenantPublicID, "ops_viewer", "ops_operator", "ops_approver", "ops_admin")
|
||||
if err != nil {
|
||||
return CostSummary{}, err
|
||||
}
|
||||
from = from.UTC()
|
||||
to = to.UTC()
|
||||
if from.IsZero() || !to.After(from) || to.Sub(from) > 366*24*time.Hour || to.After(time.Now().UTC().Add(5*time.Minute)) {
|
||||
return CostSummary{}, ErrInvalid
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT cue.usage_category,cue.metric_code,SUM(cue.quantity)::text,cue.unit,SUM(ccl.cost_micros)::bigint,ccl.currency
|
||||
FROM conversation_usage_event cue JOIN conversation_cost_line ccl ON ccl.usage_event_id=cue.id
|
||||
WHERE cue.tenant_id=$1::uuid AND cue.occurred_at>=$2 AND cue.occurred_at<$3
|
||||
GROUP BY cue.usage_category,cue.metric_code,cue.unit,ccl.currency
|
||||
ORDER BY cue.usage_category,cue.metric_code,cue.unit,ccl.currency`, currentActor.tenantID, from, to)
|
||||
if err != nil {
|
||||
return CostSummary{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := CostSummary{From: from, To: to, Lines: []CostSummaryLine{}}
|
||||
currencies := map[string]struct{}{}
|
||||
for rows.Next() {
|
||||
var item CostSummaryLine
|
||||
if err = rows.Scan(&item.UsageCategory, &item.MetricCode, &item.Quantity, &item.Unit, &item.CostMicros, &item.Currency); err != nil {
|
||||
return CostSummary{}, err
|
||||
}
|
||||
result.CostMicros += item.CostMicros
|
||||
currencies[item.Currency] = struct{}{}
|
||||
result.Lines = append(result.Lines, item)
|
||||
}
|
||||
if err = rows.Err(); err != nil {
|
||||
return CostSummary{}, err
|
||||
}
|
||||
if len(currencies) == 1 {
|
||||
for currency := range currencies {
|
||||
result.Currency = currency
|
||||
}
|
||||
} else if len(currencies) > 1 {
|
||||
return CostSummary{}, fmt.Errorf("cost summary contains multiple currencies")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
type BudgetSnapshot struct {
|
||||
TenantPolicyID *string
|
||||
DevicePolicyID *string
|
||||
BillingTimezone string
|
||||
Currency string
|
||||
DailyLimitMicros int64
|
||||
MonthlyLimitMicros int64
|
||||
MaxCallDurationSec int
|
||||
InProgressAction string
|
||||
DailySpendAtCreate int64
|
||||
MonthlySpendAtCreate int64
|
||||
}
|
||||
|
||||
type quotaRow struct {
|
||||
id, timezone, currency, action string
|
||||
daily, monthly int64
|
||||
concurrent, duration int
|
||||
}
|
||||
|
||||
func CheckQuotaTx(ctx context.Context, tx pgx.Tx, identity deviceauth.Identity, now time.Time) (*BudgetSnapshot, error) {
|
||||
tenantPolicy, err := readActiveQuota(ctx, tx, identity.TenantID, "TENANT", nil)
|
||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, err
|
||||
}
|
||||
hasTenantPolicy := err == nil
|
||||
var deviceID = identity.ID
|
||||
devicePolicy, deviceErr := readActiveQuota(ctx, tx, identity.TenantID, "DEVICE", &deviceID)
|
||||
if deviceErr != nil && !errors.Is(deviceErr, pgx.ErrNoRows) {
|
||||
return nil, deviceErr
|
||||
}
|
||||
hasDevicePolicy := deviceErr == nil
|
||||
if !hasTenantPolicy && !hasDevicePolicy {
|
||||
return nil, nil
|
||||
}
|
||||
policies := []quotaRow{}
|
||||
if hasTenantPolicy {
|
||||
policies = append(policies, tenantPolicy)
|
||||
}
|
||||
if hasDevicePolicy {
|
||||
policies = append(policies, devicePolicy)
|
||||
}
|
||||
if len(policies) == 2 && (policies[0].timezone != policies[1].timezone || policies[0].currency != policies[1].currency) {
|
||||
return nil, fmt.Errorf("tenant and device quota policy boundaries are inconsistent")
|
||||
}
|
||||
effective := policies[0]
|
||||
for _, policy := range policies {
|
||||
if policy.daily < effective.daily {
|
||||
effective.daily = policy.daily
|
||||
}
|
||||
if policy.monthly < effective.monthly {
|
||||
effective.monthly = policy.monthly
|
||||
}
|
||||
if policy.duration < effective.duration {
|
||||
effective.duration = policy.duration
|
||||
}
|
||||
if policy.action == "STOP_IMMEDIATELY" {
|
||||
effective.action = policy.action
|
||||
}
|
||||
}
|
||||
location, err := time.LoadLocation(effective.timezone)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
localNow := now.In(location)
|
||||
dayStart := time.Date(localNow.Year(), localNow.Month(), localNow.Day(), 0, 0, 0, 0, location).UTC()
|
||||
monthStart := time.Date(localNow.Year(), localNow.Month(), 1, 0, 0, 0, 0, location).UTC()
|
||||
var tenantDaily, tenantMonthly, deviceDaily, deviceMonthly int64
|
||||
if err = tx.QueryRow(ctx, `SELECT COALESCE(SUM(ccl.cost_micros) FILTER(WHERE cue.occurred_at>=$2),0)::bigint,COALESCE(SUM(ccl.cost_micros) FILTER(WHERE cue.occurred_at>=$3),0)::bigint FROM conversation_cost_line ccl JOIN conversation_usage_event cue ON cue.id=ccl.usage_event_id WHERE ccl.tenant_id=$1::uuid AND ccl.currency=$4`, identity.TenantID, dayStart, monthStart, effective.currency).Scan(&tenantDaily, &tenantMonthly); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = tx.QueryRow(ctx, `SELECT COALESCE(SUM(ccl.cost_micros) FILTER(WHERE cue.occurred_at>=$3),0)::bigint,COALESCE(SUM(ccl.cost_micros) FILTER(WHERE cue.occurred_at>=$4),0)::bigint FROM conversation_cost_line ccl JOIN conversation_usage_event cue ON cue.id=ccl.usage_event_id JOIN conversation c ON c.id=ccl.conversation_id WHERE ccl.tenant_id=$1::uuid AND c.device_id=$2::uuid AND ccl.currency=$5`, identity.TenantID, identity.ID, dayStart, monthStart, effective.currency).Scan(&deviceDaily, &deviceMonthly); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if hasTenantPolicy && (tenantDaily >= tenantPolicy.daily || tenantMonthly >= tenantPolicy.monthly) {
|
||||
return nil, ErrQuotaExceeded
|
||||
}
|
||||
if hasDevicePolicy && (deviceDaily >= devicePolicy.daily || deviceMonthly >= devicePolicy.monthly) {
|
||||
return nil, ErrQuotaExceeded
|
||||
}
|
||||
for _, policy := range policies {
|
||||
var active int
|
||||
query := `SELECT count(*) FROM conversation WHERE tenant_id=$1::uuid AND status IN ('PREPARING','ROOM_ISSUED','STARTING_AGENT','ACTIVE','STOPPING')`
|
||||
args := []any{identity.TenantID}
|
||||
if hasDevicePolicy && policy.id == devicePolicy.id {
|
||||
query += ` AND device_id=$2::uuid`
|
||||
args = append(args, identity.ID)
|
||||
}
|
||||
if err = tx.QueryRow(ctx, query, args...).Scan(&active); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if active >= policy.concurrent {
|
||||
return nil, ErrQuotaExceeded
|
||||
}
|
||||
}
|
||||
snapshot := &BudgetSnapshot{BillingTimezone: effective.timezone, Currency: effective.currency, DailyLimitMicros: effective.daily, MonthlyLimitMicros: effective.monthly, MaxCallDurationSec: effective.duration, InProgressAction: effective.action, DailySpendAtCreate: tenantDaily, MonthlySpendAtCreate: tenantMonthly}
|
||||
if hasTenantPolicy {
|
||||
snapshot.TenantPolicyID = &tenantPolicy.id
|
||||
}
|
||||
if hasDevicePolicy {
|
||||
snapshot.DevicePolicyID = &devicePolicy.id
|
||||
if deviceDaily > snapshot.DailySpendAtCreate {
|
||||
snapshot.DailySpendAtCreate = deviceDaily
|
||||
}
|
||||
if deviceMonthly > snapshot.MonthlySpendAtCreate {
|
||||
snapshot.MonthlySpendAtCreate = deviceMonthly
|
||||
}
|
||||
}
|
||||
return snapshot, nil
|
||||
}
|
||||
|
||||
func FreezeBudgetTx(ctx context.Context, tx pgx.Tx, conversationID, tenantID string, snapshot *BudgetSnapshot) error {
|
||||
if snapshot == nil {
|
||||
return nil
|
||||
}
|
||||
_, err := tx.Exec(ctx, `INSERT INTO conversation_usage_budget(conversation_id,tenant_id,tenant_policy_id,device_policy_id,billing_timezone,currency,daily_limit_micros,monthly_limit_micros,max_call_duration_sec,in_progress_action,daily_spend_at_create,monthly_spend_at_create) VALUES($1::uuid,$2::uuid,$3::uuid,$4::uuid,$5,$6,$7,$8,$9,$10,$11,$12)`, conversationID, tenantID, snapshot.TenantPolicyID, snapshot.DevicePolicyID, snapshot.BillingTimezone, snapshot.Currency, snapshot.DailyLimitMicros, snapshot.MonthlyLimitMicros, snapshot.MaxCallDurationSec, snapshot.InProgressAction, snapshot.DailySpendAtCreate, snapshot.MonthlySpendAtCreate)
|
||||
return err
|
||||
}
|
||||
|
||||
func readActiveQuota(ctx context.Context, tx pgx.Tx, tenantID, scope string, deviceID *string) (quotaRow, error) {
|
||||
var item quotaRow
|
||||
err := tx.QueryRow(ctx, `SELECT id::text,billing_timezone,currency,daily_limit_micros,monthly_limit_micros,max_concurrent_calls,max_call_duration_sec,in_progress_action FROM usage_quota_policy WHERE tenant_id=$1::uuid AND scope_type=$2 AND status='ACTIVE' AND (($2='TENANT') OR device_id=$3::uuid)`, tenantID, scope, deviceID).Scan(&item.id, &item.timezone, &item.currency, &item.daily, &item.monthly, &item.concurrent, &item.duration, &item.action)
|
||||
return item, err
|
||||
}
|
||||
Reference in New Issue
Block a user