feat(gl): add immutable postgres ledger storage
This commit is contained in:
+13
-1
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user