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