385 lines
12 KiB
Go
385 lines
12 KiB
Go
// Package ledger implements GL's transport-independent use cases.
|
|
package ledger
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
"strconv"
|
|
"time"
|
|
|
|
domain "gl/domain/ledger"
|
|
)
|
|
|
|
var ErrInvalidArgument = errors.New("invalid ledger request")
|
|
|
|
type Repository interface {
|
|
Append(context.Context, domain.Journal) (domain.AppendResult, error)
|
|
AppendEvent(context.Context, domain.TransactionEvent) (domain.TransactionEvent, bool, error)
|
|
GetByID(context.Context, string) (domain.Journal, error)
|
|
GetByIdempotencyKey(context.Context, string) (domain.Journal, error)
|
|
List(context.Context, domain.JournalFilter) ([]domain.Journal, error)
|
|
Balance(context.Context, domain.AccountReference, time.Time) (domain.Amount, error)
|
|
}
|
|
|
|
type IDGenerator func() (string, error)
|
|
|
|
type Service struct {
|
|
repository Repository
|
|
newID IDGenerator
|
|
}
|
|
|
|
func NewService(repository Repository, newID IDGenerator) *Service {
|
|
if newID == nil {
|
|
newID = newUUID
|
|
}
|
|
return &Service{repository: repository, newID: newID}
|
|
}
|
|
|
|
type EntryCommand struct {
|
|
LineNumber uint32
|
|
Account domain.AccountReference
|
|
Amount string
|
|
Description string
|
|
}
|
|
|
|
type AppendJournalCommand struct {
|
|
SourceService string
|
|
IdempotencyKey string
|
|
SourceTransactionID string
|
|
TrackingCode string
|
|
EffectKind string
|
|
EventVersion uint32
|
|
Entries []EntryCommand
|
|
ReversalOfJournalID string
|
|
OccurredAt time.Time
|
|
CorrelationID string
|
|
ActorID string
|
|
Blockchain domain.BlockchainReference
|
|
Metadata map[string]string
|
|
}
|
|
|
|
type AppendJournalResult struct {
|
|
Journal domain.Journal
|
|
AlreadyExisted bool
|
|
}
|
|
|
|
func (s *Service) AppendJournal(ctx context.Context, command AppendJournalCommand) (AppendJournalResult, error) {
|
|
journalID, err := s.newID()
|
|
if err != nil {
|
|
return AppendJournalResult{}, fmt.Errorf("generate journal id: %w", err)
|
|
}
|
|
journal := domain.Journal{
|
|
ID: journalID,
|
|
SourceService: command.SourceService,
|
|
IdempotencyKey: command.IdempotencyKey,
|
|
SourceTransactionID: command.SourceTransactionID,
|
|
TrackingCode: command.TrackingCode,
|
|
EffectKind: command.EffectKind,
|
|
EventVersion: command.EventVersion,
|
|
ReversalOfJournalID: command.ReversalOfJournalID,
|
|
OccurredAt: command.OccurredAt,
|
|
CorrelationID: command.CorrelationID,
|
|
ActorID: command.ActorID,
|
|
Blockchain: command.Blockchain,
|
|
Metadata: cloneMetadata(command.Metadata),
|
|
Entries: make([]domain.Entry, 0, len(command.Entries)),
|
|
}
|
|
for _, input := range command.Entries {
|
|
amount, parseErr := domain.ParseAmount(input.Amount)
|
|
if parseErr != nil {
|
|
return AppendJournalResult{}, invalid("entry %d amount: %v", input.LineNumber, parseErr)
|
|
}
|
|
journal.Entries = append(journal.Entries, domain.Entry{
|
|
LineNumber: input.LineNumber,
|
|
Account: input.Account,
|
|
Amount: amount,
|
|
Description: input.Description,
|
|
})
|
|
}
|
|
sort.Slice(journal.Entries, func(i, j int) bool {
|
|
return journal.Entries[i].LineNumber < journal.Entries[j].LineNumber
|
|
})
|
|
|
|
journal.PayloadHash, err = journalHash(journal)
|
|
if err != nil {
|
|
return AppendJournalResult{}, fmt.Errorf("hash journal: %w", err)
|
|
}
|
|
if err := journal.Validate(); err != nil {
|
|
return AppendJournalResult{}, invalid("%v", err)
|
|
}
|
|
if journal.ReversalOfJournalID != "" {
|
|
original, getErr := s.repository.GetByID(ctx, journal.ReversalOfJournalID)
|
|
if getErr != nil {
|
|
return AppendJournalResult{}, fmt.Errorf("read reversal target: %w", getErr)
|
|
}
|
|
if err := validateFullReversal(original, journal); err != nil {
|
|
return AppendJournalResult{}, invalid("%v", err)
|
|
}
|
|
}
|
|
|
|
appendResult, err := s.repository.Append(ctx, journal)
|
|
if err != nil {
|
|
return AppendJournalResult{}, err
|
|
}
|
|
stored, err := s.repository.GetByID(ctx, appendResult.JournalID)
|
|
if err != nil {
|
|
return AppendJournalResult{}, fmt.Errorf("read appended journal: %w", err)
|
|
}
|
|
return AppendJournalResult{Journal: stored, AlreadyExisted: appendResult.AlreadyExists}, nil
|
|
}
|
|
|
|
type AppendEventCommand struct {
|
|
SourceService string
|
|
IdempotencyKey string
|
|
SourceTransactionID string
|
|
TrackingCode string
|
|
EventVersion uint32
|
|
State domain.TransactionState
|
|
ErrorCode string
|
|
ErrorMessage string
|
|
OccurredAt time.Time
|
|
CorrelationID string
|
|
ActorID string
|
|
Blockchain domain.BlockchainReference
|
|
Metadata map[string]string
|
|
}
|
|
|
|
func (s *Service) AppendEvent(ctx context.Context, command AppendEventCommand) (domain.TransactionEvent, bool, error) {
|
|
eventID, err := s.newID()
|
|
if err != nil {
|
|
return domain.TransactionEvent{}, false, fmt.Errorf("generate event id: %w", err)
|
|
}
|
|
event := domain.TransactionEvent{
|
|
ID: eventID,
|
|
SourceService: command.SourceService,
|
|
IdempotencyKey: command.IdempotencyKey,
|
|
SourceTransactionID: command.SourceTransactionID,
|
|
TrackingCode: command.TrackingCode,
|
|
EventVersion: command.EventVersion,
|
|
State: command.State,
|
|
ErrorCode: command.ErrorCode,
|
|
ErrorMessage: command.ErrorMessage,
|
|
OccurredAt: command.OccurredAt,
|
|
CorrelationID: command.CorrelationID,
|
|
ActorID: command.ActorID,
|
|
Blockchain: command.Blockchain,
|
|
Metadata: cloneMetadata(command.Metadata),
|
|
}
|
|
event.PayloadHash, err = eventHash(event)
|
|
if err != nil {
|
|
return domain.TransactionEvent{}, false, fmt.Errorf("hash transaction event: %w", err)
|
|
}
|
|
if err := event.Validate(); err != nil {
|
|
return domain.TransactionEvent{}, false, invalid("%v", err)
|
|
}
|
|
return s.repository.AppendEvent(ctx, event)
|
|
}
|
|
|
|
func (s *Service) GetJournal(ctx context.Context, journalID, idempotencyKey string) (domain.Journal, error) {
|
|
if (journalID == "") == (idempotencyKey == "") {
|
|
return domain.Journal{}, invalid("exactly one journal lookup is required")
|
|
}
|
|
if journalID != "" {
|
|
return s.repository.GetByID(ctx, journalID)
|
|
}
|
|
return s.repository.GetByIdempotencyKey(ctx, idempotencyKey)
|
|
}
|
|
|
|
type ListCommand struct {
|
|
Account *domain.AccountReference
|
|
AssetID *int64
|
|
RecordedFrom *time.Time
|
|
RecordedTo *time.Time
|
|
PageSize uint32
|
|
PageToken string
|
|
}
|
|
|
|
func (s *Service) List(ctx context.Context, command ListCommand) ([]domain.Journal, string, error) {
|
|
if command.Account != nil {
|
|
if err := command.Account.Validate(); err != nil {
|
|
return nil, "", invalid("account: %v", err)
|
|
}
|
|
}
|
|
if command.AssetID != nil && *command.AssetID <= 0 {
|
|
return nil, "", invalid("asset id must be positive")
|
|
}
|
|
pageSize := int(command.PageSize)
|
|
if pageSize == 0 {
|
|
pageSize = 50
|
|
}
|
|
if pageSize > 200 {
|
|
return nil, "", invalid("page size must not exceed 200")
|
|
}
|
|
offset, err := decodePageToken(command.PageToken)
|
|
if err != nil {
|
|
return nil, "", invalid("page token: %v", err)
|
|
}
|
|
journals, err := s.repository.List(ctx, domain.JournalFilter{
|
|
Account: command.Account,
|
|
AssetID: command.AssetID,
|
|
RecordedFrom: command.RecordedFrom,
|
|
RecordedTo: command.RecordedTo,
|
|
Limit: pageSize + 1,
|
|
Offset: offset,
|
|
})
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
next := ""
|
|
if len(journals) > pageSize {
|
|
journals = journals[:pageSize]
|
|
next = encodePageToken(offset + pageSize)
|
|
}
|
|
return journals, next, nil
|
|
}
|
|
|
|
func (s *Service) Balance(ctx context.Context, account domain.AccountReference, asOf time.Time) (domain.Amount, error) {
|
|
if err := account.Validate(); err != nil {
|
|
return domain.Amount{}, invalid("account: %v", err)
|
|
}
|
|
return s.repository.Balance(ctx, account, asOf)
|
|
}
|
|
|
|
func (s *Service) Replay(ctx context.Context, commands []AppendJournalCommand) ([]AppendJournalResult, error) {
|
|
if len(commands) == 0 || len(commands) > 200 {
|
|
return nil, invalid("replay batch must contain between 1 and 200 journals")
|
|
}
|
|
results := make([]AppendJournalResult, 0, len(commands))
|
|
for _, command := range commands {
|
|
result, err := s.AppendJournal(ctx, command)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
results = append(results, result)
|
|
}
|
|
return results, nil
|
|
}
|
|
|
|
func validateFullReversal(original, reversal domain.Journal) error {
|
|
if len(original.Entries) != len(reversal.Entries) {
|
|
return fmt.Errorf("reversal must contain every original entry")
|
|
}
|
|
expected := make(map[string]int, len(original.Entries))
|
|
for _, entry := range original.Entries {
|
|
expected[entryKey(entry.Account, entry.Amount.Negate())]++
|
|
}
|
|
for _, entry := range reversal.Entries {
|
|
key := entryKey(entry.Account, entry.Amount)
|
|
if expected[key] == 0 {
|
|
return fmt.Errorf("reversal entries must exactly negate the original journal")
|
|
}
|
|
expected[key]--
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func entryKey(account domain.AccountReference, amount domain.Amount) string {
|
|
return fmt.Sprintf("%s\x00%s\x00%s\x00%d\x00%s", account.Class, account.OwnerType, account.OwnerID, account.AssetID, amount.String())
|
|
}
|
|
|
|
func journalHash(journal domain.Journal) (string, error) {
|
|
entries := make([]map[string]any, 0, len(journal.Entries))
|
|
for _, entry := range journal.Entries {
|
|
entries = append(entries, map[string]any{
|
|
"line_number": entry.LineNumber,
|
|
"class": entry.Account.Class,
|
|
"owner_type": entry.Account.OwnerType,
|
|
"owner_id": entry.Account.OwnerID,
|
|
"asset_id": entry.Account.AssetID,
|
|
"amount": entry.Amount.String(),
|
|
"description": entry.Description,
|
|
})
|
|
}
|
|
return hashPayload(map[string]any{
|
|
"source_service": journal.SourceService,
|
|
"idempotency_key": journal.IdempotencyKey,
|
|
"source_transaction_id": journal.SourceTransactionID,
|
|
"tracking_code": journal.TrackingCode,
|
|
"effect_kind": journal.EffectKind,
|
|
"event_version": journal.EventVersion,
|
|
"entries": entries,
|
|
"reversal_of": journal.ReversalOfJournalID,
|
|
"occurred_at": journal.OccurredAt.UTC().Format(time.RFC3339Nano),
|
|
"correlation_id": journal.CorrelationID,
|
|
"actor_id": journal.ActorID,
|
|
"blockchain": journal.Blockchain,
|
|
"metadata": journal.Metadata,
|
|
})
|
|
}
|
|
|
|
func eventHash(event domain.TransactionEvent) (string, error) {
|
|
return hashPayload(map[string]any{
|
|
"source_service": event.SourceService,
|
|
"idempotency_key": event.IdempotencyKey,
|
|
"source_transaction_id": event.SourceTransactionID,
|
|
"tracking_code": event.TrackingCode,
|
|
"event_version": event.EventVersion,
|
|
"state": event.State,
|
|
"error_code": event.ErrorCode,
|
|
"error_message": event.ErrorMessage,
|
|
"occurred_at": event.OccurredAt.UTC().Format(time.RFC3339Nano),
|
|
"correlation_id": event.CorrelationID,
|
|
"actor_id": event.ActorID,
|
|
"blockchain": event.Blockchain,
|
|
"metadata": event.Metadata,
|
|
})
|
|
}
|
|
|
|
func hashPayload(payload any) (string, error) {
|
|
encoded, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
hash := sha256.Sum256(encoded)
|
|
return hex.EncodeToString(hash[:]), nil
|
|
}
|
|
|
|
func newUUID() (string, error) {
|
|
value := make([]byte, 16)
|
|
if _, err := rand.Read(value); err != nil {
|
|
return "", err
|
|
}
|
|
value[6] = (value[6] & 0x0f) | 0x40
|
|
value[8] = (value[8] & 0x3f) | 0x80
|
|
encoded := hex.EncodeToString(value)
|
|
return encoded[0:8] + "-" + encoded[8:12] + "-" + encoded[12:16] + "-" + encoded[16:20] + "-" + encoded[20:32], nil
|
|
}
|
|
|
|
func cloneMetadata(metadata map[string]string) map[string]string {
|
|
clone := make(map[string]string, len(metadata))
|
|
for key, value := range metadata {
|
|
clone[key] = value
|
|
}
|
|
return clone
|
|
}
|
|
|
|
func invalid(format string, args ...any) error {
|
|
return fmt.Errorf("%w: %s", ErrInvalidArgument, fmt.Sprintf(format, args...))
|
|
}
|
|
|
|
func encodePageToken(offset int) string {
|
|
return base64.RawURLEncoding.EncodeToString([]byte(strconv.Itoa(offset)))
|
|
}
|
|
|
|
func decodePageToken(token string) (int, error) {
|
|
if token == "" {
|
|
return 0, nil
|
|
}
|
|
decoded, err := base64.RawURLEncoding.DecodeString(token)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
offset, err := strconv.Atoi(string(decoded))
|
|
if err != nil || offset < 0 {
|
|
return 0, fmt.Errorf("invalid offset")
|
|
}
|
|
return offset, nil
|
|
}
|