// Package payments is the in-game currency, wallet, benefit and catalog domain. // // It owns its own Postgres schema (payments) and is the only backend package // that issues SQL against it — an import-boundary test forbids any other package // from importing the payments jet code, which keeps the domain extractable into // its own database or process later. There is no cross-schema foreign key to the // account schema: an account id is a plain uuid here, kept referentially honest // in code. // // Money is carried exclusively by [Money] — an exact integer amount in a // currency's minor units — so no floating-point value ever reaches a monetary // amount, and a whole-unit currency (Vote, Star, chip) can never hold a // fraction. This file is the data-foundation layer: the currency value type; the // wallet mechanics build on the schema and this package later. package payments import ( "fmt" "math/big" ) // Currency identifies the unit a monetary amount is denominated in. type Currency string const ( // CurrencyRUB is the Russian rouble; its minor unit is the kopeck (1/100). CurrencyRUB Currency = "RUB" // CurrencyVote is the VK Vote — a whole unit with no sub-unit. CurrencyVote Currency = "VOTE" // CurrencyStar is the Telegram Star (XTR) — a whole unit with no sub-unit. CurrencyStar Currency = "XTR" // CurrencyChip is the in-game chip, the unit a value's price is quoted in — // a whole unit with no sub-unit. CurrencyChip Currency = "CHIP" ) // minorPerUnit reports how many minor units make one major unit of the currency. // Every currency except the rouble is a whole-unit currency (scale 1), so an // amount in it structurally cannot carry a fraction. func (c Currency) minorPerUnit() int64 { if c == CurrencyRUB { return 100 } return 1 } // Valid reports whether the currency is one of the known units. func (c Currency) Valid() bool { switch c { case CurrencyRUB, CurrencyVote, CurrencyStar, CurrencyChip: return true default: return false } } // Money is an exact monetary amount: a signed integer count of a currency's // minor units (rouble kopecks; Vote/Star/chip whole units). It is the sole // carrier of money in the payments domain — construction, arithmetic and // formatting all go through it, so no float ever reaches an amount and a // whole-unit currency can never hold a fraction. The zero value has an empty, // invalid currency; build a value with [MoneyFromMinor], [MoneyFromMajor] or // [ParseMoney]. type Money struct { minor int64 currency Currency } // MoneyFromMinor builds a Money from a raw count of the currency's minor units // (kopecks for the rouble, whole units otherwise). It errors on an unknown // currency. func MoneyFromMinor(minor int64, c Currency) (Money, error) { if !c.Valid() { return Money{}, fmt.Errorf("payments: unknown currency %q", c) } return Money{minor: minor, currency: c}, nil } // MoneyFromMajor builds a Money from a whole number of major units (roubles, // Votes, Stars, chips). It errors on an unknown currency or on overflow. func MoneyFromMajor(major int64, c Currency) (Money, error) { if !c.Valid() { return Money{}, fmt.Errorf("payments: unknown currency %q", c) } scaled := new(big.Int).Mul(big.NewInt(major), big.NewInt(c.minorPerUnit())) if !scaled.IsInt64() { return Money{}, fmt.Errorf("payments: %d %s overflows", major, c) } return Money{minor: scaled.Int64(), currency: c}, nil } // ParseMoney parses a decimal amount (e.g. "149.50", "250") in the currency, // exactly and without floating point (via math/big). It rejects a value with // finer precision than the currency allows — in particular, any fractional part // for a whole-unit currency — which is the gate that keeps a fraction out of an // integer currency. func ParseMoney(text string, c Currency) (Money, error) { if !c.Valid() { return Money{}, fmt.Errorf("payments: unknown currency %q", c) } r, ok := new(big.Rat).SetString(text) if !ok { return Money{}, fmt.Errorf("payments: %q is not a valid amount", text) } scaled := new(big.Rat).Mul(r, new(big.Rat).SetInt64(c.minorPerUnit())) if !scaled.IsInt() { return Money{}, fmt.Errorf("payments: %q has finer precision than %s allows", text, c) } num := scaled.Num() // the denominator is 1 once scaled to an integer if !num.IsInt64() { return Money{}, fmt.Errorf("payments: %q overflows %s", text, c) } return Money{minor: num.Int64(), currency: c}, nil } // Minor returns the amount as a count of the currency's minor units — the value // persisted to an amount column. func (m Money) Minor() int64 { return m.minor } // Currency returns the amount's currency. func (m Money) Currency() Currency { return m.currency } // IsZero reports whether the amount is zero. func (m Money) IsZero() bool { return m.minor == 0 } // Add returns the sum of the two amounts. It errors when the currencies differ. func (m Money) Add(o Money) (Money, error) { if m.currency != o.currency { return Money{}, fmt.Errorf("payments: cannot add %s to %s", o.currency, m.currency) } return Money{minor: m.minor + o.minor, currency: m.currency}, nil } // Cmp compares the two amounts, returning -1, 0 or +1. It errors when the // currencies differ. func (m Money) Cmp(o Money) (int, error) { if m.currency != o.currency { return 0, fmt.Errorf("payments: cannot compare %s to %s", o.currency, m.currency) } switch { case m.minor < o.minor: return -1, nil case m.minor > o.minor: return 1, nil default: return 0, nil } } // String renders the amount as " ", with the currency's // fractional digits and no floating point (e.g. "149.50 RUB", "250 XTR"). func (m Money) String() string { scale := m.currency.minorPerUnit() if scale == 1 { return fmt.Sprintf("%d %s", m.minor, m.currency) } neg := m.minor < 0 abs := m.minor if neg { abs = -abs } width := 0 for s := scale; s > 1; s /= 10 { width++ } sign := "" if neg { sign = "-" } return fmt.Sprintf("%s%d.%0*d %s", sign, abs/scale, width, abs%scale, m.currency) }