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
+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},
},
}
}