ce8b5026c1
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Has been skipped
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m45s
Stand up the payments data foundation: a self-contained `payments` Postgres schema for the in-game currency, wallets, benefits, catalog, orders and the append-only operations ledger, behind a domain package — nothing wired to real money yet. - Migration 00010: the `payments` schema and a NOLOGIN confinement role (ALL on payments.*, nothing on backend); the ledger with a BEFORE UPDATE/DELETE append-only trigger and a partial idempotency index; the materialised balances/benefits; the catalog (atoms seeded) + products + per-method prices; the typed single-row config; orders and payment_events. There is no cross-schema foreign key — account_id is a plain uuid kept consistent in code, which keeps the domain extractable. Expand-contract and reversible. - Money is a bigint in the currency's minor units carried by a `Money` value type (exact, math/big): no float ever touches an amount, and a whole-unit currency cannot hold a fraction. - Extend jetgen to generate the payments schema; construct the service in the composition root behind a narrow interface with a boot reachability check. - Tests: integration (role confinement via SET ROLE, the append-only trigger, CHECK constraints, the idempotency index, and a forward+backward migration), Money unit tests, and an import-boundary test keeping the payments jet code private to the domain. - Docs: PLAN.md, docs/PAYMENTS.md (+ _ru mirror) updated to the built model.
172 lines
5.9 KiB
Go
172 lines
5.9 KiB
Go
// 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 "<value> <currency>", 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)
|
|
}
|