7860efce48
Add the payment-intake write path (provider-agnostic) and the Robokassa direct-rail glue, both unit-tested; transport, wire and UI follow. - payments: extend the ledger insert to thread order_id/provider/ provider_payment_id (spend/grant pass nil); add the order store (create/read/expire + a pack-price loader) and the fund credit — a fund ledger row + a guarded balance upsert + mark-paid in one tx, idempotent on the (provider, provider_payment_id) unique index, cache invalidated after commit. A valid callback is honoured even on an expired order. Service CreateOrder/Fund/ExpireOrders; Money.Major for the provider amount field. - robokassa: build the signed hosted-payment URL (SHA-256, order id via Shp_order, InvId unused) and verify the Result callback signature (Password2), extracting the order and amount. Receipt/fiscalisation is configured shop-side, so no Receipt parameter is sent.
178 lines
6.1 KiB
Go
178 lines
6.1 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
|
|
}
|
|
}
|
|
|
|
// Major renders the amount as a decimal string without the currency, with the currency's
|
|
// fractional digits and no floating point (e.g. "149.50", "250") — the form a provider's amount
|
|
// field (Robokassa OutSum) takes.
|
|
func (m Money) Major() string {
|
|
scale := m.currency.minorPerUnit()
|
|
if scale == 1 {
|
|
return fmt.Sprintf("%d", m.minor)
|
|
}
|
|
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", sign, abs/scale, width, abs%scale)
|
|
}
|
|
|
|
// String renders the amount as "<value> <currency>" (e.g. "149.50 RUB", "250 XTR").
|
|
func (m Money) String() string {
|
|
return m.Major() + " " + string(m.currency)
|
|
}
|