Files
scrabble-game/backend/internal/payments/payments.go
T
Ilia Denisov 7860efce48 feat(payments): order-flow and fund credit engine + the Robokassa adapter
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.
2026-07-09 16:48:48 +02:00

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)
}