diff --git a/cmd/gl/main.go b/cmd/gl/main.go index 7e13b62..d883fd6 100644 --- a/cmd/gl/main.go +++ b/cmd/gl/main.go @@ -10,6 +10,7 @@ import ( "gl/application/health" "gl/infrastructure/config" + "gl/infrastructure/postgres" grpcadapter "gl/interface/grpc" ) @@ -26,7 +27,18 @@ func main() { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() - handler := grpcadapter.NewHealthHandler(health.NewService(nil)) + database, err := postgres.Open(ctx, cfg.Database) + if err != nil { + slog.Error("open GL database", "error", err) + os.Exit(1) + } + defer database.Close() + if err := postgres.Migrate(ctx, database); err != nil { + slog.Error("migrate GL database", "error", err) + os.Exit(1) + } + + handler := grpcadapter.NewHealthHandler(health.NewService(database)) slog.Info("starting GL gRPC service", "host", cfg.GRPC.Host, "port", cfg.GRPC.Port) serverConfig := grpcadapter.ServerConfig{ Host: cfg.GRPC.Host, diff --git a/domain/ledger/amount.go b/domain/ledger/amount.go new file mode 100644 index 0000000..c7bb464 --- /dev/null +++ b/domain/ledger/amount.go @@ -0,0 +1,115 @@ +// Package ledger contains GL's framework-independent financial model. +package ledger + +import ( + "fmt" + "math/big" + "strings" + "unicode" +) + +const ( + AmountPrecision = 38 + AmountScale = 18 +) + +var amountFactor = new(big.Int).Exp(big.NewInt(10), big.NewInt(AmountScale), nil) + +// Amount is an exact fixed-point decimal with PostgreSQL numeric(38,18) +// semantics. Its zero value is a valid zero amount. +type Amount struct { + units big.Int +} + +func ParseAmount(value string) (Amount, error) { + if value == "" { + return Amount{}, fmt.Errorf("amount is required") + } + + negative := value[0] == '-' + unsigned := value + if negative { + unsigned = value[1:] + } + if unsigned == "" || strings.HasPrefix(unsigned, "+") { + return Amount{}, fmt.Errorf("invalid amount %q", value) + } + + parts := strings.Split(unsigned, ".") + if len(parts) > 2 || parts[0] == "" { + return Amount{}, fmt.Errorf("invalid amount %q", value) + } + for _, part := range parts { + if part == "" || strings.IndexFunc(part, func(r rune) bool { return !unicode.IsDigit(r) }) >= 0 { + return Amount{}, fmt.Errorf("invalid amount %q", value) + } + } + if len(parts[0]) > 1 && parts[0][0] == '0' { + return Amount{}, fmt.Errorf("amount must not contain leading zeroes") + } + if len(strings.TrimLeft(parts[0], "0")) > AmountPrecision-AmountScale { + return Amount{}, fmt.Errorf("amount exceeds integer precision %d", AmountPrecision-AmountScale) + } + + fraction := "" + if len(parts) == 2 { + fraction = parts[1] + if len(fraction) > AmountScale { + return Amount{}, fmt.Errorf("amount exceeds scale %d", AmountScale) + } + } + digits := strings.TrimLeft(parts[0]+fraction, "0") + if len(digits) > AmountPrecision { + return Amount{}, fmt.Errorf("amount exceeds precision %d", AmountPrecision) + } + + whole := new(big.Int) + whole.SetString(parts[0], 10) + units := new(big.Int).Mul(whole, amountFactor) + if fraction != "" { + padded := fraction + strings.Repeat("0", AmountScale-len(fraction)) + fractional := new(big.Int) + fractional.SetString(padded, 10) + units.Add(units, fractional) + } + if negative { + units.Neg(units) + } + + return Amount{units: *units}, nil +} + +func (a Amount) IsZero() bool { + return a.units.Sign() == 0 +} + +func (a Amount) Add(other Amount) Amount { + var result big.Int + result.Add(&a.units, &other.units) + return Amount{units: result} +} + +func (a Amount) Negate() Amount { + var result big.Int + result.Neg(&a.units) + return Amount{units: result} +} + +func (a Amount) String() string { + if a.units.Sign() == 0 { + return "0" + } + + abs := new(big.Int).Abs(new(big.Int).Set(&a.units)) + whole, fraction := new(big.Int), new(big.Int) + whole.QuoRem(abs, amountFactor, fraction) + value := whole.String() + if fraction.Sign() != 0 { + fractionText := fmt.Sprintf("%018s", fraction.String()) + value += "." + strings.TrimRight(fractionText, "0") + } + if a.units.Sign() < 0 { + return "-" + value + } + return value +} diff --git a/domain/ledger/amount_test.go b/domain/ledger/amount_test.go new file mode 100644 index 0000000..08f56a5 --- /dev/null +++ b/domain/ledger/amount_test.go @@ -0,0 +1,38 @@ +package ledger + +import "testing" + +func TestParseAmountCanonicalizesExactValues(t *testing.T) { + for input, expected := range map[string]string{ + "0": "0", + "1": "1", + "1.2300": "1.23", + "-0.000000000000000001": "-0.000000000000000001", + "99999999999999999999": "99999999999999999999", + } { + amount, err := ParseAmount(input) + if err != nil { + t.Fatalf("ParseAmount(%q): %v", input, err) + } + if got := amount.String(); got != expected { + t.Fatalf("ParseAmount(%q) = %q, want %q", input, got, expected) + } + } +} + +func TestParseAmountRejectsInvalidValues(t *testing.T) { + for _, input := range []string{"", "+1", "01", ".1", "1.", "1e2", "1.0000000000000000001", "123456789012345678901234567890123456789"} { + if _, err := ParseAmount(input); err == nil { + t.Fatalf("ParseAmount(%q) succeeded", input) + } + } +} + +func TestAmountAdditionIsExact(t *testing.T) { + one, _ := ParseAmount("0.1") + two, _ := ParseAmount("0.2") + minusThree, _ := ParseAmount("-0.3") + if got := one.Add(two).Add(minusThree); !got.IsZero() { + t.Fatalf("expected exact zero, got %s", got.String()) + } +} diff --git a/domain/ledger/journal.go b/domain/ledger/journal.go new file mode 100644 index 0000000..47c82b3 --- /dev/null +++ b/domain/ledger/journal.go @@ -0,0 +1,137 @@ +package ledger + +import ( + "encoding/hex" + "fmt" + "strings" + "time" +) + +type AccountClass string + +const ( + AccountClassUserAvailable AccountClass = "USER_AVAILABLE" + AccountClassUserFrozen AccountClass = "USER_FROZEN" + AccountClassExternalBlockchain AccountClass = "EXTERNAL_BLOCKCHAIN" + AccountClassTreasury AccountClass = "TREASURY" + AccountClassMarketClearing AccountClass = "MARKET_CLEARING" + AccountClassIPGClearing AccountClass = "IPG_CLEARING" + AccountClassCommissionRevenue AccountClass = "COMMISSION_REVENUE" +) + +func (c AccountClass) Valid() bool { + switch c { + case AccountClassUserAvailable, + AccountClassUserFrozen, + AccountClassExternalBlockchain, + AccountClassTreasury, + AccountClassMarketClearing, + AccountClassIPGClearing, + AccountClassCommissionRevenue: + return true + default: + return false + } +} + +type AccountReference struct { + Class AccountClass + OwnerType string + OwnerID string + AssetID int64 +} + +func (a AccountReference) Validate() error { + if !a.Class.Valid() { + return fmt.Errorf("invalid account class %q", a.Class) + } + if a.AssetID <= 0 { + return fmt.Errorf("asset id must be positive") + } + if a.Class == AccountClassUserAvailable || a.Class == AccountClassUserFrozen { + if a.OwnerType == "" || a.OwnerID == "" { + return fmt.Errorf("user account requires owner type and id") + } + } + return nil +} + +type Entry struct { + LineNumber uint32 + Account AccountReference + Amount Amount + Description string +} + +type BlockchainReference struct { + Network string + TransactionHash string + LedgerSequence string +} + +type Journal struct { + ID string + SourceService string + IdempotencyKey string + SourceTransactionID string + TrackingCode string + EffectKind string + EventVersion uint32 + Entries []Entry + ReversalOfJournalID string + OccurredAt time.Time + CorrelationID string + ActorID string + Blockchain BlockchainReference + Metadata map[string]string + PayloadHash string +} + +func (j Journal) Validate() error { + if j.ID == "" || j.SourceService == "" || j.IdempotencyKey == "" || j.SourceTransactionID == "" || j.EffectKind == "" { + return fmt.Errorf("journal identity fields are required") + } + if j.EventVersion == 0 { + return fmt.Errorf("event version must be positive") + } + if j.OccurredAt.IsZero() { + return fmt.Errorf("occurred time is required") + } + if len(j.PayloadHash) != 64 { + return fmt.Errorf("payload hash must be a SHA-256 hex string") + } + if _, err := hex.DecodeString(j.PayloadHash); err != nil { + return fmt.Errorf("payload hash must be a SHA-256 hex string") + } + if j.PayloadHash != strings.ToLower(j.PayloadHash) { + return fmt.Errorf("payload hash must use lowercase hexadecimal") + } + if len(j.Entries) < 2 { + return fmt.Errorf("journal requires at least two entries") + } + + lines := make(map[uint32]struct{}, len(j.Entries)) + balances := make(map[int64]Amount) + for _, entry := range j.Entries { + if entry.LineNumber == 0 { + return fmt.Errorf("entry line number must be positive") + } + if _, exists := lines[entry.LineNumber]; exists { + return fmt.Errorf("duplicate entry line number %d", entry.LineNumber) + } + lines[entry.LineNumber] = struct{}{} + if err := entry.Account.Validate(); err != nil { + return fmt.Errorf("entry %d: %w", entry.LineNumber, err) + } + if entry.Amount.IsZero() { + return fmt.Errorf("entry %d amount must not be zero", entry.LineNumber) + } + balances[entry.Account.AssetID] = balances[entry.Account.AssetID].Add(entry.Amount) + } + for assetID, balance := range balances { + if !balance.IsZero() { + return fmt.Errorf("asset %d entries are unbalanced by %s", assetID, balance.String()) + } + } + return nil +} diff --git a/domain/ledger/journal_test.go b/domain/ledger/journal_test.go new file mode 100644 index 0000000..68d6054 --- /dev/null +++ b/domain/ledger/journal_test.go @@ -0,0 +1,50 @@ +package ledger + +import ( + "strings" + "testing" + "time" +) + +func TestJournalValidationBalancesEachAsset(t *testing.T) { + journal := validJournal(t) + if err := journal.Validate(); err != nil { + t.Fatal(err) + } + + journal.Entries[1].Amount, _ = ParseAmount("9") + if err := journal.Validate(); err == nil || !strings.Contains(err.Error(), "unbalanced") { + t.Fatalf("expected unbalanced error, got %v", err) + } +} + +func TestJournalValidationRejectsDuplicateLines(t *testing.T) { + journal := validJournal(t) + journal.Entries[1].LineNumber = journal.Entries[0].LineNumber + if err := journal.Validate(); err == nil || !strings.Contains(err.Error(), "duplicate") { + t.Fatalf("expected duplicate line error, got %v", err) + } +} + +func validJournal(t *testing.T) Journal { + t.Helper() + debit, err := ParseAmount("-10") + if err != nil { + t.Fatal(err) + } + credit := debit.Negate() + return Journal{ + ID: "11111111-1111-4111-8111-111111111111", + SourceService: "wallet", + IdempotencyKey: "wallet:1:internal-transfer:v1", + SourceTransactionID: "1", + EffectKind: "internal-transfer", + EventVersion: 1, + OccurredAt: time.Unix(1, 0).UTC(), + PayloadHash: strings.Repeat("a", 64), + Entries: []Entry{ + {LineNumber: 1, Account: AccountReference{Class: AccountClassUserAvailable, OwnerType: "user", OwnerID: "1", AssetID: 5}, Amount: debit}, + {LineNumber: 2, Account: AccountReference{Class: AccountClassUserAvailable, OwnerType: "user", OwnerID: "2", AssetID: 5}, Amount: credit}, + }, + } +} diff --git a/go.mod b/go.mod index 764ea76..05f8c38 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module gl go 1.24 require ( + github.com/jackc/pgx/v5 v5.4.3 github.com/knadh/koanf/parsers/toml v0.1.0 github.com/knadh/koanf/providers/file v1.2.1 github.com/knadh/koanf/v2 v2.3.4 @@ -13,11 +14,16 @@ require ( require ( github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect + github.com/jackc/puddle/v2 v2.2.1 // indirect github.com/knadh/koanf/maps v0.1.2 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/pelletier/go-toml v1.9.5 // indirect + golang.org/x/crypto v0.26.0 // indirect golang.org/x/net v0.28.0 // indirect + golang.org/x/sync v0.8.0 // indirect golang.org/x/sys v0.32.0 // indirect golang.org/x/text v0.17.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142 // indirect diff --git a/go.sum b/go.sum index 5ea5e05..3dce6d6 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,4 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= @@ -6,6 +7,14 @@ github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9L github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.4.3 h1:cxFyXhxlvAifxnkKKdlxv8XqUf59tDlYjnV5YYfsJJY= +github.com/jackc/pgx/v5 v5.4.3/go.mod h1:Ig06C2Vu0t5qXC60W8sqIthScaEnFvojjj9dSljmHRA= +github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= +github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo= github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI= github.com/knadh/koanf/parsers/toml v0.1.0 h1:S2hLqS4TgWZYj4/7mI5m1CQQcWurxUz6ODgOub/6LCI= @@ -22,10 +31,17 @@ github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3v github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw= +golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54= golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE= golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg= +golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc= @@ -36,5 +52,7 @@ google.golang.org/grpc v1.67.1 h1:zWnc1Vrcno+lHZCOofnIMvycFcc0QRGIzm9dhnDX68E= google.golang.org/grpc v1.67.1/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA= google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/infrastructure/postgres/database.go b/infrastructure/postgres/database.go new file mode 100644 index 0000000..a9f8272 --- /dev/null +++ b/infrastructure/postgres/database.go @@ -0,0 +1,83 @@ +// Package postgres provides GL's PostgreSQL adapters. +package postgres + +import ( + "context" + "fmt" + "net/url" + "strconv" + + "gl/infrastructure/config" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgxpool" +) + +type Row interface { + Scan(dest ...any) error +} + +type Tx interface { + Exec(context.Context, string, ...any) (pgconn.CommandTag, error) + QueryRow(context.Context, string, ...any) Row + Commit(context.Context) error + Rollback(context.Context) error +} + +type Database interface { + Begin(context.Context) (Tx, error) + QueryRow(context.Context, string, ...any) Row + Ping(context.Context) error + Close() +} + +type Pool struct { + pool *pgxpool.Pool +} + +func Open(ctx context.Context, cfg config.DatabaseConfig) (*Pool, error) { + connectionURL := &url.URL{ + Scheme: "postgres", + User: url.UserPassword(cfg.User, cfg.Password), + Host: cfg.Host + ":" + strconv.Itoa(cfg.Port), + Path: cfg.Name, + } + query := connectionURL.Query() + query.Set("sslmode", cfg.SSLMode) + connectionURL.RawQuery = query.Encode() + + pool, err := pgxpool.New(ctx, connectionURL.String()) + if err != nil { + return nil, fmt.Errorf("create postgres pool: %w", err) + } + return &Pool{pool: pool}, nil +} + +func (p *Pool) Begin(ctx context.Context) (Tx, error) { + tx, err := p.pool.BeginTx(ctx, pgx.TxOptions{}) + if err != nil { + return nil, err + } + return txAdapter{Tx: tx}, nil +} + +func (p *Pool) Ping(ctx context.Context) error { + return p.pool.Ping(ctx) +} + +func (p *Pool) QueryRow(ctx context.Context, sql string, args ...any) Row { + return p.pool.QueryRow(ctx, sql, args...) +} + +func (p *Pool) Close() { + p.pool.Close() +} + +type txAdapter struct { + pgx.Tx +} + +func (t txAdapter) QueryRow(ctx context.Context, sql string, args ...any) Row { + return t.Tx.QueryRow(ctx, sql, args...) +} diff --git a/infrastructure/postgres/journal_repository.go b/infrastructure/postgres/journal_repository.go new file mode 100644 index 0000000..4bc5126 --- /dev/null +++ b/infrastructure/postgres/journal_repository.go @@ -0,0 +1,179 @@ +package postgres + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + "gl/domain/ledger" + + "github.com/jackc/pgx/v5" +) + +var ( + ErrIdempotencyConflict = errors.New("idempotency key belongs to a different payload") + ErrIncompleteJournal = errors.New("idempotency key belongs to an unsealed journal") +) + +type AppendResult struct { + JournalID string + AlreadyExists bool +} + +type JournalRepository struct { + database Database +} + +func NewJournalRepository(database Database) *JournalRepository { + return &JournalRepository{database: database} +} + +func (r *JournalRepository) Append(ctx context.Context, journal ledger.Journal) (result AppendResult, err error) { + if err := journal.Validate(); err != nil { + return AppendResult{}, fmt.Errorf("validate journal: %w", err) + } + metadataValues := journal.Metadata + if metadataValues == nil { + metadataValues = map[string]string{} + } + metadata, err := json.Marshal(metadataValues) + if err != nil { + return AppendResult{}, fmt.Errorf("encode metadata: %w", err) + } + + tx, err := r.database.Begin(ctx) + if err != nil { + return AppendResult{}, fmt.Errorf("begin journal append: %w", err) + } + defer func() { + if err != nil { + _ = tx.Rollback(ctx) + } + }() + + var insertedID string + err = tx.QueryRow(ctx, insertJournalSQL, + journal.ID, + journal.SourceService, + journal.IdempotencyKey, + journal.SourceTransactionID, + journal.TrackingCode, + journal.EffectKind, + journal.EventVersion, + nullIfEmpty(journal.ReversalOfJournalID), + journal.OccurredAt, + journal.CorrelationID, + journal.ActorID, + journal.Blockchain.Network, + journal.Blockchain.TransactionHash, + journal.Blockchain.LedgerSequence, + metadata, + journal.PayloadHash, + ).Scan(&insertedID) + if errors.Is(err, pgx.ErrNoRows) { + _ = tx.Rollback(ctx) + return r.resolveDuplicate(ctx, journal) + } + if err != nil { + return AppendResult{}, fmt.Errorf("insert journal: %w", err) + } + + for _, entry := range journal.Entries { + var accountID int64 + err = tx.QueryRow(ctx, insertAccountSQL, + entry.Account.Class, + entry.Account.OwnerType, + entry.Account.OwnerID, + entry.Account.AssetID, + ).Scan(&accountID) + if errors.Is(err, pgx.ErrNoRows) { + err = tx.QueryRow(ctx, selectAccountSQL, + entry.Account.Class, + entry.Account.OwnerType, + entry.Account.OwnerID, + entry.Account.AssetID, + ).Scan(&accountID) + } + if err != nil { + return AppendResult{}, fmt.Errorf("resolve entry %d account: %w", entry.LineNumber, err) + } + + if _, err = tx.Exec(ctx, insertEntrySQL, + journal.ID, + entry.LineNumber, + accountID, + entry.Account.AssetID, + entry.Amount.String(), + entry.Description, + ); err != nil { + return AppendResult{}, fmt.Errorf("insert entry %d: %w", entry.LineNumber, err) + } + } + + if _, err = tx.Exec(ctx, "UPDATE journals SET sealed_at = clock_timestamp() WHERE id = $1", journal.ID); err != nil { + return AppendResult{}, fmt.Errorf("seal journal: %w", err) + } + if err = tx.Commit(ctx); err != nil { + return AppendResult{}, fmt.Errorf("commit journal: %w", err) + } + return AppendResult{JournalID: insertedID}, nil +} + +func (r *JournalRepository) resolveDuplicate(ctx context.Context, journal ledger.Journal) (AppendResult, error) { + var ( + journalID string + payloadHash string + sealed bool + ) + err := r.database.QueryRow(ctx, selectIdempotencySQL, journal.IdempotencyKey).Scan(&journalID, &payloadHash, &sealed) + if err != nil { + return AppendResult{}, fmt.Errorf("read idempotent journal: %w", err) + } + if payloadHash != journal.PayloadHash { + return AppendResult{}, ErrIdempotencyConflict + } + if !sealed { + return AppendResult{}, ErrIncompleteJournal + } + return AppendResult{JournalID: journalID, AlreadyExists: true}, nil +} + +func nullIfEmpty(value string) any { + if value == "" { + return nil + } + return value +} + +const insertJournalSQL = ` +INSERT INTO journals ( + id, source_service, idempotency_key, source_transaction_id, tracking_code, + effect_kind, event_version, reversal_of_journal_id, occurred_at, + correlation_id, actor_id, blockchain_network, blockchain_transaction_hash, + blockchain_ledger_sequence, metadata, payload_hash +) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16 +) +ON CONFLICT (idempotency_key) DO NOTHING +RETURNING id` + +const insertAccountSQL = ` +INSERT INTO ledger_accounts (class, owner_type, owner_id, asset_id) +VALUES ($1, $2, $3, $4) +ON CONFLICT (class, owner_type, owner_id, asset_id) DO NOTHING +RETURNING id` + +const selectAccountSQL = ` +SELECT id FROM ledger_accounts +WHERE class = $1 AND owner_type = $2 AND owner_id = $3 AND asset_id = $4` + +const insertEntrySQL = ` +INSERT INTO journal_entries ( + journal_id, line_number, account_id, asset_id, amount, description +) VALUES ($1, $2, $3, $4, $5, $6)` + +const selectIdempotencySQL = ` +SELECT id, payload_hash, sealed_at IS NOT NULL +FROM journals +WHERE idempotency_key = $1` diff --git a/infrastructure/postgres/journal_repository_test.go b/infrastructure/postgres/journal_repository_test.go new file mode 100644 index 0000000..12bf4d3 --- /dev/null +++ b/infrastructure/postgres/journal_repository_test.go @@ -0,0 +1,213 @@ +package postgres + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "gl/domain/ledger" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +type fakeRow struct { + values []any + err error +} + +func (r fakeRow) Scan(dest ...any) error { + if r.err != nil { + return r.err + } + if len(dest) != len(r.values) { + return errors.New("unexpected scan width") + } + for index, value := range r.values { + switch target := dest[index].(type) { + case *string: + *target = value.(string) + case *int64: + *target = value.(int64) + case *bool: + *target = value.(bool) + default: + return errors.New("unsupported scan target") + } + } + return nil +} + +type fakeTx struct { + rows []Row + execCount int + execErrAt int + committed bool + rolledBack bool +} + +func (t *fakeTx) Exec(context.Context, string, ...any) (pgconn.CommandTag, error) { + t.execCount++ + if t.execCount == t.execErrAt { + return pgconn.CommandTag{}, errors.New("exec failed") + } + return pgconn.NewCommandTag("INSERT 0 1"), nil +} + +func (t *fakeTx) QueryRow(context.Context, string, ...any) Row { + row := t.rows[0] + t.rows = t.rows[1:] + return row +} + +func (t *fakeTx) Commit(context.Context) error { + t.committed = true + return nil +} + +func (t *fakeTx) Rollback(context.Context) error { + t.rolledBack = true + return nil +} + +type fakeDatabase struct { + tx *fakeTx + directRow Row + beginCalled bool +} + +func (d *fakeDatabase) Begin(context.Context) (Tx, error) { + d.beginCalled = true + return d.tx, nil +} + +func (d *fakeDatabase) QueryRow(context.Context, string, ...any) Row { return d.directRow } +func (d *fakeDatabase) Ping(context.Context) error { return nil } +func (d *fakeDatabase) Close() {} + +func TestJournalRepositoryAppendCommitsJournalEntriesAndSeal(t *testing.T) { + journal := repositoryJournal(t) + tx := &fakeTx{rows: []Row{ + fakeRow{values: []any{journal.ID}}, + fakeRow{values: []any{int64(10)}}, + fakeRow{values: []any{int64(20)}}, + }} + database := &fakeDatabase{tx: tx} + + result, err := NewJournalRepository(database).Append(context.Background(), journal) + if err != nil { + t.Fatal(err) + } + if result.JournalID != journal.ID || result.AlreadyExists { + t.Fatalf("unexpected result: %+v", result) + } + if !tx.committed || tx.rolledBack { + t.Fatalf("unexpected transaction state: %+v", tx) + } + if tx.execCount != 3 { + t.Fatalf("expected two entry inserts and one seal, got %d execs", tx.execCount) + } +} + +func TestJournalRepositoryReturnsExistingIdenticalJournal(t *testing.T) { + journal := repositoryJournal(t) + tx := &fakeTx{rows: []Row{fakeRow{err: pgx.ErrNoRows}}} + database := &fakeDatabase{ + tx: tx, + directRow: fakeRow{values: []any{journal.ID, journal.PayloadHash, true}}, + } + + result, err := NewJournalRepository(database).Append(context.Background(), journal) + if err != nil { + t.Fatal(err) + } + if !result.AlreadyExists || result.JournalID != journal.ID { + t.Fatalf("unexpected result: %+v", result) + } + if !tx.rolledBack || tx.committed { + t.Fatalf("duplicate transaction was not rolled back: %+v", tx) + } +} + +func TestJournalRepositoryRejectsConflictingIdempotencyPayload(t *testing.T) { + journal := repositoryJournal(t) + database := &fakeDatabase{ + tx: &fakeTx{rows: []Row{fakeRow{err: pgx.ErrNoRows}}}, + directRow: fakeRow{values: []any{journal.ID, strings.Repeat("b", 64), true}}, + } + + _, err := NewJournalRepository(database).Append(context.Background(), journal) + if !errors.Is(err, ErrIdempotencyConflict) { + t.Fatalf("expected idempotency conflict, got %v", err) + } +} + +func TestJournalRepositoryRollsBackEntryFailure(t *testing.T) { + journal := repositoryJournal(t) + tx := &fakeTx{ + rows: []Row{ + fakeRow{values: []any{journal.ID}}, + fakeRow{values: []any{int64(10)}}, + }, + execErrAt: 1, + } + database := &fakeDatabase{tx: tx} + + if _, err := NewJournalRepository(database).Append(context.Background(), journal); err == nil { + t.Fatal("expected entry insert error") + } + if !tx.rolledBack || tx.committed { + t.Fatalf("failed append was not rolled back: %+v", tx) + } +} + +func TestJournalRepositoryValidatesBeforeOpeningTransaction(t *testing.T) { + journal := repositoryJournal(t) + journal.Entries[1].Amount, _ = ledger.ParseAmount("9") + database := &fakeDatabase{tx: &fakeTx{}} + + if _, err := NewJournalRepository(database).Append(context.Background(), journal); err == nil { + t.Fatal("expected validation error") + } + if database.beginCalled { + t.Fatal("invalid journal opened a database transaction") + } +} + +func repositoryJournal(t *testing.T) ledger.Journal { + t.Helper() + debit, err := ledger.ParseAmount("-10.25") + if err != nil { + t.Fatal(err) + } + return ledger.Journal{ + ID: "11111111-1111-4111-8111-111111111111", + SourceService: "wallet", + IdempotencyKey: "wallet:1:internal-transfer:v1", + SourceTransactionID: "1", + TrackingCode: "track-1", + EffectKind: "internal-transfer", + EventVersion: 1, + OccurredAt: time.Unix(1, 0).UTC(), + PayloadHash: strings.Repeat("a", 64), + Metadata: map[string]string{"origin": "test"}, + Entries: []ledger.Entry{ + { + LineNumber: 1, + Account: ledger.AccountReference{ + Class: ledger.AccountClassUserAvailable, OwnerType: "user", OwnerID: "1", AssetID: 5, + }, + Amount: debit, + }, + { + LineNumber: 2, + Account: ledger.AccountReference{ + Class: ledger.AccountClassUserAvailable, OwnerType: "user", OwnerID: "2", AssetID: 5, + }, + Amount: debit.Negate(), + }, + }, + } +} diff --git a/infrastructure/postgres/migrate.go b/infrastructure/postgres/migrate.go new file mode 100644 index 0000000..35dad7b --- /dev/null +++ b/infrastructure/postgres/migrate.go @@ -0,0 +1,75 @@ +package postgres + +import ( + "context" + "embed" + "fmt" + "io/fs" + "sort" + "strconv" + "strings" +) + +//go:embed migrations/*.up.sql +var migrationFiles embed.FS + +const migrationLockID int64 = 674301 + +func Migrate(ctx context.Context, database Database) (err error) { + tx, err := database.Begin(ctx) + if err != nil { + return fmt.Errorf("begin migrations: %w", err) + } + defer func() { + if err != nil { + _ = tx.Rollback(ctx) + } + }() + + if _, err = tx.Exec(ctx, "SELECT pg_advisory_xact_lock($1)", migrationLockID); err != nil { + return fmt.Errorf("lock migrations: %w", err) + } + if _, err = tx.Exec(ctx, `CREATE TABLE IF NOT EXISTS ledger_schema_migrations ( + version bigint PRIMARY KEY, + applied_at timestamptz NOT NULL DEFAULT clock_timestamp() + )`); err != nil { + return fmt.Errorf("create migration ledger: %w", err) + } + + files, err := fs.Glob(migrationFiles, "migrations/*.up.sql") + if err != nil { + return fmt.Errorf("list migrations: %w", err) + } + sort.Strings(files) + for _, name := range files { + versionText := strings.SplitN(strings.TrimPrefix(name, "migrations/"), "_", 2)[0] + version, parseErr := strconv.ParseInt(versionText, 10, 64) + if parseErr != nil { + return fmt.Errorf("parse migration %s: %w", name, parseErr) + } + + var applied bool + if err = tx.QueryRow(ctx, "SELECT EXISTS (SELECT 1 FROM ledger_schema_migrations WHERE version = $1)", version).Scan(&applied); err != nil { + return fmt.Errorf("check migration %s: %w", name, err) + } + if applied { + continue + } + + contents, readErr := migrationFiles.ReadFile(name) + if readErr != nil { + return fmt.Errorf("read migration %s: %w", name, readErr) + } + if _, err = tx.Exec(ctx, string(contents)); err != nil { + return fmt.Errorf("apply migration %s: %w", name, err) + } + if _, err = tx.Exec(ctx, "INSERT INTO ledger_schema_migrations (version) VALUES ($1) ON CONFLICT DO NOTHING", version); err != nil { + return fmt.Errorf("record migration %s: %w", name, err) + } + } + + if err = tx.Commit(ctx); err != nil { + return fmt.Errorf("commit migrations: %w", err) + } + return nil +} diff --git a/infrastructure/postgres/migration_test.go b/infrastructure/postgres/migration_test.go new file mode 100644 index 0000000..5b612ba --- /dev/null +++ b/infrastructure/postgres/migration_test.go @@ -0,0 +1,26 @@ +package postgres + +import ( + "strings" + "testing" +) + +func TestInitialMigrationContainsLedgerSafetyGuards(t *testing.T) { + contents, err := migrationFiles.ReadFile("migrations/000001_init.up.sql") + if err != nil { + t.Fatal(err) + } + schema := string(contents) + for _, required := range []string{ + "amount numeric(38, 18)", + "idempotency_key text NOT NULL UNIQUE", + "guard_journal_seal", + "journal is not balanced per asset", + "cannot append to a sealed journal", + "reject_ledger_mutation", + } { + if !strings.Contains(schema, required) { + t.Fatalf("migration is missing %q", required) + } + } +} diff --git a/infrastructure/postgres/migrations/000001_init.down.sql b/infrastructure/postgres/migrations/000001_init.down.sql new file mode 100644 index 0000000..5fa6e5b --- /dev/null +++ b/infrastructure/postgres/migrations/000001_init.down.sql @@ -0,0 +1,14 @@ +DROP TRIGGER IF EXISTS transaction_events_reject_update_or_delete ON transaction_events; +DROP TRIGGER IF EXISTS accounts_reject_update_or_delete ON ledger_accounts; +DROP TRIGGER IF EXISTS entries_reject_update_or_delete ON journal_entries; +DROP TRIGGER IF EXISTS entries_guard_insert ON journal_entries; +DROP TRIGGER IF EXISTS journals_reject_delete ON journals; +DROP TRIGGER IF EXISTS journals_guard_update ON journals; +DROP FUNCTION IF EXISTS guard_entry_insert(); +DROP FUNCTION IF EXISTS guard_journal_seal(); +DROP FUNCTION IF EXISTS reject_ledger_mutation(); +DROP TABLE IF EXISTS transaction_events; +DROP TABLE IF EXISTS journal_entries; +DROP TABLE IF EXISTS journals; +DROP TABLE IF EXISTS ledger_accounts; +DROP TABLE IF EXISTS ledger_schema_migrations; diff --git a/infrastructure/postgres/migrations/000001_init.up.sql b/infrastructure/postgres/migrations/000001_init.up.sql new file mode 100644 index 0000000..b577c96 --- /dev/null +++ b/infrastructure/postgres/migrations/000001_init.up.sql @@ -0,0 +1,172 @@ +CREATE TABLE IF NOT EXISTS ledger_schema_migrations ( + version bigint PRIMARY KEY, + applied_at timestamptz NOT NULL DEFAULT clock_timestamp() +); + +CREATE TABLE ledger_accounts ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + class text NOT NULL CHECK (class IN ( + 'USER_AVAILABLE', + 'USER_FROZEN', + 'EXTERNAL_BLOCKCHAIN', + 'TREASURY', + 'MARKET_CLEARING', + 'IPG_CLEARING', + 'COMMISSION_REVENUE' + )), + owner_type text NOT NULL DEFAULT '', + owner_id text NOT NULL DEFAULT '', + asset_id bigint NOT NULL CHECK (asset_id > 0), + created_at timestamptz NOT NULL DEFAULT clock_timestamp(), + UNIQUE (class, owner_type, owner_id, asset_id), + UNIQUE (id, asset_id), + CHECK ( + class NOT IN ('USER_AVAILABLE', 'USER_FROZEN') + OR (owner_type <> '' AND owner_id <> '') + ) +); + +CREATE TABLE journals ( + id uuid PRIMARY KEY, + source_service text NOT NULL CHECK (source_service <> ''), + idempotency_key text NOT NULL UNIQUE CHECK (idempotency_key <> ''), + source_transaction_id text NOT NULL CHECK (source_transaction_id <> ''), + tracking_code text NOT NULL DEFAULT '', + effect_kind text NOT NULL CHECK (effect_kind <> ''), + event_version integer NOT NULL CHECK (event_version > 0), + reversal_of_journal_id uuid REFERENCES journals (id), + occurred_at timestamptz NOT NULL, + recorded_at timestamptz NOT NULL DEFAULT clock_timestamp(), + sealed_at timestamptz, + correlation_id text NOT NULL DEFAULT '', + actor_id text NOT NULL DEFAULT '', + blockchain_network text NOT NULL DEFAULT '', + blockchain_transaction_hash text NOT NULL DEFAULT '', + blockchain_ledger_sequence text NOT NULL DEFAULT '', + metadata jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(metadata) = 'object'), + payload_hash char(64) NOT NULL CHECK (payload_hash ~ '^[0-9a-f]{64}$'), + CHECK (reversal_of_journal_id IS NULL OR reversal_of_journal_id <> id) +); + +CREATE INDEX journals_source_transaction_idx + ON journals (source_service, source_transaction_id); + +CREATE INDEX journals_recorded_at_idx ON journals (recorded_at, id); + +CREATE TABLE journal_entries ( + journal_id uuid NOT NULL REFERENCES journals (id), + line_number integer NOT NULL CHECK (line_number > 0), + account_id bigint NOT NULL, + asset_id bigint NOT NULL CHECK (asset_id > 0), + amount numeric(38, 18) NOT NULL CHECK (amount <> 0), + description text NOT NULL DEFAULT '', + PRIMARY KEY (journal_id, line_number), + FOREIGN KEY (account_id, asset_id) REFERENCES ledger_accounts (id, asset_id) +); + +CREATE INDEX journal_entries_account_idx + ON journal_entries (account_id, journal_id, line_number); + +CREATE TABLE transaction_events ( + id uuid PRIMARY KEY, + source_service text NOT NULL CHECK (source_service <> ''), + idempotency_key text NOT NULL UNIQUE CHECK (idempotency_key <> ''), + source_transaction_id text NOT NULL CHECK (source_transaction_id <> ''), + tracking_code text NOT NULL DEFAULT '', + event_version integer NOT NULL CHECK (event_version > 0), + state text NOT NULL CHECK (state IN ( + 'CREATED', + 'PENDING_TRANSACTION', + 'PENDING_ADMIN', + 'SUCCESSFUL', + 'FAILED', + 'SUSPENDED' + )), + error_code text NOT NULL DEFAULT '', + error_message text NOT NULL DEFAULT '', + occurred_at timestamptz NOT NULL, + recorded_at timestamptz NOT NULL DEFAULT clock_timestamp(), + correlation_id text NOT NULL DEFAULT '', + actor_id text NOT NULL DEFAULT '', + blockchain_network text NOT NULL DEFAULT '', + blockchain_transaction_hash text NOT NULL DEFAULT '', + blockchain_ledger_sequence text NOT NULL DEFAULT '', + metadata jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(metadata) = 'object'), + payload_hash char(64) NOT NULL CHECK (payload_hash ~ '^[0-9a-f]{64}$') +); + +CREATE INDEX transaction_events_source_idx + ON transaction_events (source_service, source_transaction_id, event_version); + +CREATE FUNCTION reject_ledger_mutation() RETURNS trigger +LANGUAGE plpgsql AS $$ +BEGIN + RAISE EXCEPTION '% is append-only', TG_TABLE_NAME + USING ERRCODE = '55000'; +END; +$$; + +CREATE FUNCTION guard_journal_seal() RETURNS trigger +LANGUAGE plpgsql AS $$ +BEGIN + IF OLD.sealed_at IS NOT NULL + OR NEW.sealed_at IS NULL + OR (to_jsonb(NEW) - 'sealed_at') IS DISTINCT FROM (to_jsonb(OLD) - 'sealed_at') THEN + RAISE EXCEPTION 'journals are immutable except for initial sealing' + USING ERRCODE = '55000'; + END IF; + + IF (SELECT count(*) FROM journal_entries WHERE journal_id = NEW.id) < 2 THEN + RAISE EXCEPTION 'journal requires at least two entries' + USING ERRCODE = '23514'; + END IF; + + IF EXISTS ( + SELECT asset_id + FROM journal_entries + WHERE journal_id = NEW.id + GROUP BY asset_id + HAVING sum(amount) <> 0 + ) THEN + RAISE EXCEPTION 'journal is not balanced per asset' + USING ERRCODE = '23514'; + END IF; + + RETURN NEW; +END; +$$; + +CREATE FUNCTION guard_entry_insert() RETURNS trigger +LANGUAGE plpgsql AS $$ +BEGIN + IF (SELECT sealed_at IS NOT NULL FROM journals WHERE id = NEW.journal_id) THEN + RAISE EXCEPTION 'cannot append to a sealed journal' + USING ERRCODE = '55000'; + END IF; + RETURN NEW; +END; +$$; + +CREATE TRIGGER journals_guard_update +BEFORE UPDATE ON journals +FOR EACH ROW EXECUTE FUNCTION guard_journal_seal(); + +CREATE TRIGGER journals_reject_delete +BEFORE DELETE ON journals +FOR EACH ROW EXECUTE FUNCTION reject_ledger_mutation(); + +CREATE TRIGGER entries_guard_insert +BEFORE INSERT ON journal_entries +FOR EACH ROW EXECUTE FUNCTION guard_entry_insert(); + +CREATE TRIGGER entries_reject_update_or_delete +BEFORE UPDATE OR DELETE ON journal_entries +FOR EACH ROW EXECUTE FUNCTION reject_ledger_mutation(); + +CREATE TRIGGER accounts_reject_update_or_delete +BEFORE UPDATE OR DELETE ON ledger_accounts +FOR EACH ROW EXECUTE FUNCTION reject_ledger_mutation(); + +CREATE TRIGGER transaction_events_reject_update_or_delete +BEFORE UPDATE OR DELETE ON transaction_events +FOR EACH ROW EXECUTE FUNCTION reject_ledger_mutation();