Files
GL/domain/ledger/journal.go
T

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
}