feat(gl): add immutable postgres ledger storage

This commit is contained in:
2026-08-14 19:00:45 +03:30
parent b1e0b01598
commit 0b3eaa0c3d
14 changed files with 1139 additions and 1 deletions
+13 -1
View File
@@ -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,
+115
View File
@@ -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
}
+38
View File
@@ -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())
}
}
+137
View File
@@ -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
}
+50
View File
@@ -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},
},
}
}
+6
View File
@@ -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
+18
View File
@@ -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=
+83
View File
@@ -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...)
}
@@ -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`
@@ -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(),
},
},
}
}
+75
View File
@@ -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
}
+26
View File
@@ -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)
}
}
}
@@ -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;
@@ -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();