feat(gl): expose ledger application and grpc operations
This commit is contained in:
@@ -0,0 +1,384 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user