// 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 }