openmeter / ledger /primitives.go
Leon4gr45's picture
Upload folder using huggingface_hub (part 5)
cee2387 verified
Raw
History Blame Contribute Delete
10.1 kB
package ledger
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/alpacahq/alpacadecimal"
"github.com/samber/mo"
"github.com/openmeterio/openmeter/pkg/currencyx"
"github.com/openmeterio/openmeter/pkg/models"
)
// ----------------------------------------------------------------------------
// Account primitives
// ----------------------------------------------------------------------------
// PostingAddress encapsulates an address you can post-against. This is a one-to-one mapping to a SubAccount, this address format exists for routing purposes where the full sub-sccount isn't needed.
type PostingAddress interface {
models.Equaler[PostingAddress]
SubAccountID() string
AccountType() AccountType
Route() SubAccountRoute
}
type Balance = alpacadecimal.Decimal
// SubAccount is an actual address you can post against. It has all required routing information provided.
// Accounts describe ownership and purpose while SubAccounts parameterize the actual posting address.
type SubAccount interface {
// Returns the address of the sub-account
Address() PostingAddress
// AccountID returns the identifier of the parent account.
AccountID() models.NamespacedID
// Route returns the routing values of the sub-account.
Route() Route
}
// RouteFilter is the set of route fields that can be used to filter sub-accounts and query balances.
type RouteFilter struct {
Currency currencyx.Code
// Non-currency fields are retained for near-future expansion.
TaxCode mo.Option[*string]
TaxBehavior mo.Option[*TaxBehavior]
// Features is an exact-match filter for sub-account routes.
Features mo.Option[[]string]
// MatchFeature matches unrestricted routes and routes containing this single feature key.
MatchFeature string
CostBasis mo.Option[*alpacadecimal.Decimal]
// CreditPriority is only meaningful for customer_fbo queries.
CreditPriority *int
// TransactionAuthorizationStatus is currently only meaningful for customer_receivable queries.
// Nil means "do not filter by authorization status", not "open".
TransactionAuthorizationStatus *TransactionAuthorizationStatus
}
// Account represents a ledger account tying together multiple sub-accounts.
// Accounts describe ownership and purpose while SubAccounts parameterize the actual posting address.
type Account interface {
ID() models.NamespacedID
Type() AccountType
}
// ----------------------------------------------------------------------------
// Transaction primitives
// ----------------------------------------------------------------------------
type EntryInput interface {
PostingAddress() PostingAddress
Amount() alpacadecimal.Decimal
IdentityKey() string
SchemaVersion() EntrySchemaVersion
SourceChargeID() *string
SpendChargeID() *string
Annotations() models.Annotations
}
type EntrySchemaVersion int
const (
EntrySchemaVersionLegacy EntrySchemaVersion = 1
EntrySchemaVersionCurrent EntrySchemaVersion = 2
)
type Entry interface {
EntryInput
ID() models.NamespacedID
TransactionID() models.NamespacedID
}
type TransactionInput interface {
BookedAt() time.Time
EntryInputs() []EntryInput
Annotations() models.Annotations
AsGroupInput(namespace string, annotations models.Annotations) TransactionGroupInput
}
type ImpactFilter struct {
AccountType AccountType
Route RouteFilter
}
// Transaction represents a list of entries booked at the same time
type Transaction interface {
Cursor() TransactionCursor
BookedAt() time.Time
Entries() []Entry
ID() models.NamespacedID
Annotations() models.Annotations
}
type TransactionCursor struct {
BookedAt time.Time
CreatedAt time.Time
ID models.NamespacedID
}
// Compare returns cursor ordering by BookedAt, then CreatedAt, then ID.
// It returns -1 if c < other, 0 if equal, and 1 if c > other.
func (c TransactionCursor) Compare(other TransactionCursor) int {
switch {
case c.BookedAt.Before(other.BookedAt):
return -1
case c.BookedAt.After(other.BookedAt):
return 1
}
switch {
case c.CreatedAt.Before(other.CreatedAt):
return -1
case c.CreatedAt.After(other.CreatedAt):
return 1
}
return strings.Compare(c.ID.ID, other.ID.ID)
}
func (c TransactionCursor) Validate() error {
var errs []error
if c.BookedAt.IsZero() {
errs = append(errs, fmt.Errorf("booked_at is zero"))
}
if c.CreatedAt.IsZero() {
errs = append(errs, fmt.Errorf("created_at is zero"))
}
if err := c.ID.Validate(); err != nil {
errs = append(errs, fmt.Errorf("id is invalid: %w", err))
}
return errors.Join(errs...)
}
type TransactionGroupInput interface {
Namespace() string
Transactions() []TransactionInput
Annotations() models.Annotations
}
// TransactionGroup represents a group of transactions written to the ledger at the same time
type TransactionGroup interface {
ID() models.NamespacedID
Transactions() []Transaction
Annotations() models.Annotations
}
// ----------------------------------------------------------------------------
// Ledger primitives
// ----------------------------------------------------------------------------
type Ledger interface {
// CommitGroup commits a list of transactions on the Ledger atomically
CommitGroup(ctx context.Context, group TransactionGroupInput) (TransactionGroup, error)
// GetTransactionGroup loads a previously committed transaction group including its transactions.
GetTransactionGroup(ctx context.Context, id models.NamespacedID) (TransactionGroup, error)
// ListTransactions lists transactions on the Ledger according to some filters
//
// TODO: Cursoring gets problematic due to diff between wall_clock and booked_at. It would be convenient to return in order of booked_at as that simplifies parsing. This API will likely change.
ListTransactions(ctx context.Context, params ListTransactionsInput) (ListTransactionsResult, error)
}
type ListTransactionsInput struct {
Namespace string
Cursor *TransactionCursor
Before *TransactionCursor
Limit int
TransactionID *models.NamespacedID
// AccountIDs scopes the query to transactions with entries on these accounts.
AccountIDs []string
Currency *currencyx.Code
AsOf *time.Time
Route RouteFilter
CreditMovement ListTransactionsCreditMovement
// AnnotationFilters matches transactions whose annotations contain all the given key-value pairs.
AnnotationFilters map[string]string
// ExcludeAnnotationFilters excludes transactions whose annotations contain any given key-value pair.
ExcludeAnnotationFilters map[string]string
}
type ListTransactionsResult struct {
Items []Transaction
NextCursor *TransactionCursor
}
func (i ListTransactionsInput) Validate() error {
if i.Limit < 1 {
return ErrListTransactionsInputInvalid.WithAttrs(models.Attributes{
"reason": "limit_invalid",
"limit": i.Limit,
})
}
if i.Cursor != nil {
if err := i.Cursor.Validate(); err != nil {
return ErrListTransactionsInputInvalid.WithAttrs(models.Attributes{
"reason": "cursor_invalid",
"cursor": i.Cursor,
"error": err,
})
}
}
if i.Before != nil {
if err := i.Before.Validate(); err != nil {
return ErrListTransactionsInputInvalid.WithAttrs(models.Attributes{
"reason": "before_invalid",
"before": i.Before,
"error": err,
})
}
}
if i.Cursor != nil && i.Before != nil {
return ErrListTransactionsInputInvalid.WithAttrs(models.Attributes{
"reason": "after_before_both_set",
})
}
if i.TransactionID != nil {
if err := i.TransactionID.Validate(); err != nil {
return ErrListTransactionsInputInvalid.WithAttrs(models.Attributes{
"reason": "transaction_id_invalid",
"transaction_id": i.TransactionID,
"error": err,
})
}
}
if i.Currency != nil {
if err := i.Currency.Validate(); err != nil {
return ErrListTransactionsInputInvalid.WithAttrs(models.Attributes{
"reason": "currency_invalid",
"currency": i.Currency,
"error": err,
})
}
}
if i.AsOf != nil && i.AsOf.IsZero() {
return ErrListTransactionsInputInvalid.WithAttrs(models.Attributes{
"reason": "as_of_invalid",
"as_of": i.AsOf,
})
}
if err := validateListTransactionsRouteFilter(i.Route); err != nil {
return ErrListTransactionsInputInvalid.WithAttrs(models.Attributes{
"reason": "route_invalid",
"route": i.Route,
"error": err,
})
}
switch i.CreditMovement {
case ListTransactionsCreditMovementUnspecified, ListTransactionsCreditMovementPositive, ListTransactionsCreditMovementNegative:
default:
return ErrListTransactionsInputInvalid.WithAttrs(models.Attributes{
"reason": "credit_movement_invalid",
"credit_movement": i.CreditMovement,
})
}
return nil
}
func validateListTransactionsRouteFilter(route RouteFilter) error {
var errs []error
if route.TaxCode.IsPresent() {
errs = append(errs, errors.New("tax code filter is not supported"))
}
if route.TaxBehavior.IsPresent() {
errs = append(errs, errors.New("tax behavior filter is not supported"))
}
if route.CostBasis.IsPresent() {
errs = append(errs, errors.New("cost basis filter is not supported"))
}
if route.CreditPriority != nil {
errs = append(errs, errors.New("credit priority filter is not supported"))
}
if route.TransactionAuthorizationStatus != nil {
errs = append(errs, errors.New("transaction authorization status filter is not supported"))
}
if route.Features.IsPresent() && route.MatchFeature != "" {
errs = append(errs, errors.New("features and match feature filters cannot be combined"))
}
if route.Features.IsPresent() {
features, _ := route.Features.Get()
if err := validateFeatures(features); err != nil {
errs = append(errs, fmt.Errorf("features: %w", err))
}
}
if route.MatchFeature != "" {
if err := validateFeatures([]string{route.MatchFeature}); err != nil {
errs = append(errs, fmt.Errorf("match feature: %w", err))
}
}
return errors.Join(errs...)
}
type ListTransactionsCreditMovement uint8
const (
ListTransactionsCreditMovementUnspecified ListTransactionsCreditMovement = iota
ListTransactionsCreditMovementPositive
ListTransactionsCreditMovementNegative
)