feat(gl): expose ledger application and grpc operations

This commit is contained in:
2026-08-14 19:11:29 +03:30
parent 0b3eaa0c3d
commit 76905ab451
17 changed files with 1724 additions and 16 deletions
+384
View File
@@ -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
}
+223
View File
@@ -0,0 +1,223 @@
package ledger
import (
"context"
"errors"
"strings"
"testing"
"time"
domain "gl/domain/ledger"
)
type repositoryStub struct {
journals map[string]domain.Journal
byKey map[string]string
events map[string]domain.TransactionEvent
appendCalls int
list []domain.Journal
balance domain.Amount
lastAttempt domain.Journal
}
func newRepositoryStub() *repositoryStub {
return &repositoryStub{
journals: make(map[string]domain.Journal),
byKey: make(map[string]string),
events: make(map[string]domain.TransactionEvent),
}
}
func (r *repositoryStub) Append(_ context.Context, journal domain.Journal) (domain.AppendResult, error) {
r.appendCalls++
r.lastAttempt = journal
if existingID, ok := r.byKey[journal.IdempotencyKey]; ok {
return domain.AppendResult{JournalID: existingID, AlreadyExists: true}, nil
}
journal.RecordedAt = time.Unix(2, 0).UTC()
r.journals[journal.ID] = journal
r.byKey[journal.IdempotencyKey] = journal.ID
return domain.AppendResult{JournalID: journal.ID}, nil
}
func (r *repositoryStub) AppendEvent(_ context.Context, event domain.TransactionEvent) (domain.TransactionEvent, bool, error) {
if existing, ok := r.events[event.IdempotencyKey]; ok {
return existing, true, nil
}
event.RecordedAt = time.Unix(2, 0).UTC()
r.events[event.IdempotencyKey] = event
return event, false, nil
}
func (r *repositoryStub) GetByID(_ context.Context, id string) (domain.Journal, error) {
journal, ok := r.journals[id]
if !ok {
return domain.Journal{}, errors.New("not found")
}
return journal, nil
}
func (r *repositoryStub) GetByIdempotencyKey(_ context.Context, key string) (domain.Journal, error) {
id, ok := r.byKey[key]
if !ok {
return domain.Journal{}, errors.New("not found")
}
return r.journals[id], nil
}
func (r *repositoryStub) List(context.Context, domain.JournalFilter) ([]domain.Journal, error) {
return append([]domain.Journal(nil), r.list...), nil
}
func (r *repositoryStub) Balance(context.Context, domain.AccountReference, time.Time) (domain.Amount, error) {
return r.balance, nil
}
func TestAppendJournalIsBalancedCanonicalAndIdempotent(t *testing.T) {
repository := newRepositoryStub()
service := NewService(repository, fixedID("11111111-1111-4111-8111-111111111111"))
command := validAppendCommand()
first, err := service.AppendJournal(context.Background(), command)
if err != nil {
t.Fatal(err)
}
command.Entries[0], command.Entries[1] = command.Entries[1], command.Entries[0]
second, err := service.AppendJournal(context.Background(), command)
if err != nil {
t.Fatal(err)
}
if first.AlreadyExisted || !second.AlreadyExisted {
t.Fatalf("unexpected idempotency results: first=%+v second=%+v", first, second)
}
if first.Journal.PayloadHash == "" || first.Journal.PayloadHash != second.Journal.PayloadHash {
t.Fatalf("unexpected payload hashes: %q %q", first.Journal.PayloadHash, second.Journal.PayloadHash)
}
if repository.lastAttempt.PayloadHash != first.Journal.PayloadHash {
t.Fatal("entry order changed the canonical payload hash")
}
if got := first.Journal.Entries[0].Amount.String(); got != "-10.25" {
t.Fatalf("amount was not canonical: %s", got)
}
}
func TestAppendJournalRejectsInvalidAmountBeforeRepository(t *testing.T) {
repository := newRepositoryStub()
service := NewService(repository, fixedID("11111111-1111-4111-8111-111111111111"))
command := validAppendCommand()
command.Entries[0].Amount = "NaN"
_, err := service.AppendJournal(context.Background(), command)
if !errors.Is(err, ErrInvalidArgument) {
t.Fatalf("expected invalid argument, got %v", err)
}
if repository.appendCalls != 0 {
t.Fatal("invalid journal reached repository")
}
}
func TestAppendJournalRequiresExactFullReversal(t *testing.T) {
repository := newRepositoryStub()
service := NewService(repository, fixedID("22222222-2222-4222-8222-222222222222"))
originalCommand := validAppendCommand()
original, err := NewService(repository, fixedID("11111111-1111-4111-8111-111111111111")).AppendJournal(context.Background(), originalCommand)
if err != nil {
t.Fatal(err)
}
reversal := validAppendCommand()
reversal.IdempotencyKey = "wallet:1:internal-transfer-reversal:v1"
reversal.EffectKind = "internal-transfer-reversal"
reversal.ReversalOfJournalID = original.Journal.ID
reversal.Entries[0].Amount = "10.25"
reversal.Entries[1].Amount = "-10.25"
if _, err := service.AppendJournal(context.Background(), reversal); err != nil {
t.Fatalf("valid reversal failed: %v", err)
}
reversal.IdempotencyKey = "wallet:1:bad-reversal:v1"
reversal.Entries[0].Account.OwnerID = "different-user"
if _, err := service.AppendJournal(context.Background(), reversal); !errors.Is(err, ErrInvalidArgument) {
t.Fatalf("expected invalid reversal, got %v", err)
}
}
func TestAppendEventIsIdempotent(t *testing.T) {
repository := newRepositoryStub()
service := NewService(repository, fixedID("11111111-1111-4111-8111-111111111111"))
command := AppendEventCommand{
SourceService: "wallet",
IdempotencyKey: "wallet:1:status:v1",
SourceTransactionID: "1",
EventVersion: 1,
State: domain.TransactionStateCreated,
OccurredAt: time.Unix(1, 0).UTC(),
}
first, existed, err := service.AppendEvent(context.Background(), command)
if err != nil || existed {
t.Fatalf("first append: existed=%v err=%v", existed, err)
}
second, existed, err := service.AppendEvent(context.Background(), command)
if err != nil || !existed || first.PayloadHash != second.PayloadHash {
t.Fatalf("second append: existed=%v err=%v first=%+v second=%+v", existed, err, first, second)
}
}
func TestListUsesOpaquePageToken(t *testing.T) {
repository := newRepositoryStub()
repository.list = make([]domain.Journal, 3)
service := NewService(repository, nil)
journals, next, err := service.List(context.Background(), ListCommand{PageSize: 2})
if err != nil {
t.Fatal(err)
}
if len(journals) != 2 || next == "" {
t.Fatalf("unexpected page: len=%d next=%q", len(journals), next)
}
if _, err := decodePageToken(next); err != nil {
t.Fatalf("invalid generated page token: %v", err)
}
}
func TestNewUUIDProducesRFC4122Shape(t *testing.T) {
id, err := newUUID()
if err != nil {
t.Fatal(err)
}
if len(id) != 36 || id[14] != '4' || !strings.Contains("89ab", string(id[19])) {
t.Fatalf("unexpected UUID: %q", id)
}
}
func validAppendCommand() AppendJournalCommand {
return AppendJournalCommand{
SourceService: "wallet",
IdempotencyKey: "wallet:1:internal-transfer:v1",
SourceTransactionID: "1",
EffectKind: "internal-transfer",
EventVersion: 1,
OccurredAt: time.Unix(1, 0).UTC(),
Entries: []EntryCommand{
{
LineNumber: 1,
Account: domain.AccountReference{
Class: domain.AccountClassUserAvailable, OwnerType: "user", OwnerID: "1", AssetID: 5,
},
Amount: "-10.2500",
},
{
LineNumber: 2,
Account: domain.AccountReference{
Class: domain.AccountClassUserAvailable, OwnerType: "user", OwnerID: "2", AssetID: 5,
},
Amount: "10.25",
},
},
}
}
func fixedID(id string) IDGenerator {
return func() (string, error) { return id, nil }
}