Files
NianAIGC/backend/internal/billing/ledger.go

90 lines
3.0 KiB
Go

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)
}