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