Files

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
}