153 lines
3.9 KiB
Go
153 lines
3.9 KiB
Go
package ledger
|
|
|
|
import (
|
|
"encoding/hex"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type AccountClass string
|
|
|
|
const (
|
|
AccountClassUserAvailable AccountClass = "USER_AVAILABLE"
|
|
AccountClassUserFrozen AccountClass = "USER_FROZEN"
|
|
AccountClassExternalBlockchain AccountClass = "EXTERNAL_BLOCKCHAIN"
|
|
AccountClassTreasury AccountClass = "TREASURY"
|
|
AccountClassMarketClearing AccountClass = "MARKET_CLEARING"
|
|
AccountClassIPGClearing AccountClass = "IPG_CLEARING"
|
|
AccountClassCommissionRevenue AccountClass = "COMMISSION_REVENUE"
|
|
)
|
|
|
|
func (c AccountClass) Valid() bool {
|
|
switch c {
|
|
case AccountClassUserAvailable,
|
|
AccountClassUserFrozen,
|
|
AccountClassExternalBlockchain,
|
|
AccountClassTreasury,
|
|
AccountClassMarketClearing,
|
|
AccountClassIPGClearing,
|
|
AccountClassCommissionRevenue:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
type AccountReference struct {
|
|
Class AccountClass
|
|
OwnerType string
|
|
OwnerID string
|
|
AssetID int64
|
|
}
|
|
|
|
func (a AccountReference) Validate() error {
|
|
if !a.Class.Valid() {
|
|
return fmt.Errorf("invalid account class %q", a.Class)
|
|
}
|
|
if a.AssetID <= 0 {
|
|
return fmt.Errorf("asset id must be positive")
|
|
}
|
|
if a.Class == AccountClassUserAvailable || a.Class == AccountClassUserFrozen {
|
|
if a.OwnerType == "" || a.OwnerID == "" {
|
|
return fmt.Errorf("user account requires owner type and id")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type Entry struct {
|
|
LineNumber uint32
|
|
Account AccountReference
|
|
Amount Amount
|
|
Description string
|
|
}
|
|
|
|
type BlockchainReference struct {
|
|
Network string
|
|
TransactionHash string
|
|
LedgerSequence string
|
|
}
|
|
|
|
type Journal struct {
|
|
ID string
|
|
SourceService string
|
|
IdempotencyKey string
|
|
SourceTransactionID string
|
|
TrackingCode string
|
|
EffectKind string
|
|
EventVersion uint32
|
|
Entries []Entry
|
|
ReversalOfJournalID string
|
|
OccurredAt time.Time
|
|
RecordedAt time.Time
|
|
CorrelationID string
|
|
ActorID string
|
|
Blockchain BlockchainReference
|
|
Metadata map[string]string
|
|
PayloadHash string
|
|
}
|
|
|
|
type AppendResult struct {
|
|
JournalID string
|
|
AlreadyExists bool
|
|
}
|
|
|
|
type JournalFilter struct {
|
|
Account *AccountReference
|
|
AssetID *int64
|
|
RecordedFrom *time.Time
|
|
RecordedTo *time.Time
|
|
Limit int
|
|
Offset int
|
|
}
|
|
|
|
func (j Journal) Validate() error {
|
|
if j.ID == "" || j.SourceService == "" || j.IdempotencyKey == "" || j.SourceTransactionID == "" || j.EffectKind == "" {
|
|
return fmt.Errorf("journal identity fields are required")
|
|
}
|
|
if j.EventVersion == 0 {
|
|
return fmt.Errorf("event version must be positive")
|
|
}
|
|
if j.OccurredAt.IsZero() {
|
|
return fmt.Errorf("occurred time is required")
|
|
}
|
|
if len(j.PayloadHash) != 64 {
|
|
return fmt.Errorf("payload hash must be a SHA-256 hex string")
|
|
}
|
|
if _, err := hex.DecodeString(j.PayloadHash); err != nil {
|
|
return fmt.Errorf("payload hash must be a SHA-256 hex string")
|
|
}
|
|
if j.PayloadHash != strings.ToLower(j.PayloadHash) {
|
|
return fmt.Errorf("payload hash must use lowercase hexadecimal")
|
|
}
|
|
if len(j.Entries) < 2 {
|
|
return fmt.Errorf("journal requires at least two entries")
|
|
}
|
|
|
|
lines := make(map[uint32]struct{}, len(j.Entries))
|
|
balances := make(map[int64]Amount)
|
|
for _, entry := range j.Entries {
|
|
if entry.LineNumber == 0 {
|
|
return fmt.Errorf("entry line number must be positive")
|
|
}
|
|
if _, exists := lines[entry.LineNumber]; exists {
|
|
return fmt.Errorf("duplicate entry line number %d", entry.LineNumber)
|
|
}
|
|
lines[entry.LineNumber] = struct{}{}
|
|
if err := entry.Account.Validate(); err != nil {
|
|
return fmt.Errorf("entry %d: %w", entry.LineNumber, err)
|
|
}
|
|
if entry.Amount.IsZero() {
|
|
return fmt.Errorf("entry %d amount must not be zero", entry.LineNumber)
|
|
}
|
|
balances[entry.Account.AssetID] = balances[entry.Account.AssetID].Add(entry.Amount)
|
|
}
|
|
for assetID, balance := range balances {
|
|
if !balance.IsZero() {
|
|
return fmt.Errorf("asset %d entries are unbalanced by %s", assetID, balance.String())
|
|
}
|
|
}
|
|
return nil
|
|
}
|