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.
This commit is contained in:
Ilia Denisov
2026-07-09 16:48:48 +02:00
parent ca60025fbe
commit 7860efce48
7 changed files with 658 additions and 11 deletions
+11 -5
View File
@@ -147,12 +147,13 @@ func (m Money) Cmp(o Money) (int, error) {
}
}
// 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 {
// 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 %s", m.minor, m.currency)
return fmt.Sprintf("%d", m.minor)
}
neg := m.minor < 0
abs := m.minor
@@ -167,5 +168,10 @@ func (m Money) String() string {
if neg {
sign = "-"
}
return fmt.Sprintf("%s%d.%0*d %s", sign, abs/scale, width, abs%scale, m.currency)
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)
}