92ba527575
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 25s
CI / ui (pull_request) Successful in 1m17s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m50s
Replace Robokassa with YooKassa as the RUB direct-rail provider. The wallet
model is untouched: one `direct` segment, the same spend wall, the same
per-channel merchant shops (D42) and `shop` on the order (D44).
The two providers are not shaped alike, and that drives the change:
- Opening a purchase is now an outbound API call (`POST /v3/payments`,
single-stage capture, redirect confirmation). The order id is both the
`Idempotence-Key` and `metadata.order_id`, so a retried create cannot mint a
second payment and a notification always resolves to its order.
- YooKassa does NOT sign notifications, so the body is never evidence: it only
names a payment, which is re-read with `GET /v3/payments/{id}`, and only that
answer is acted on. Two guards ride on it — the payment's metadata must name
the order, and its `test` flag must match the shop's, so a test-shop payment
can never credit real chips. The sender address is checked against YooKassa's
published ranges first, which stops a forger turning each fabricated
notification into an outbound call of ours.
- A notification lost for good would leave the money taken and the chips unowed,
silently. The existing pending-order reaper now asks the provider about each
order that reached its expiry age carrying a payment id, and credits the ones
really paid — one request per order over its whole life, not polling.
- `payment.canceled` records a `failed` event, so a declined payment is finally
surfaced to the customer as PAYMENTS.md §9 already specified.
- The admin refund moves the money through `POST /v3/refunds` before recording
anything; a failed call records nothing, so the ledger cannot claim a refund
that did not happen, and the recorded id is the provider's own.
- YooKassa has no cabinet-side generic receipt: «Чеки от ЮKassa» registers one
only if the request carries it, so every payment and refund now sends an
itemized `receipt` to the D36 confirmed email. The VAT rate code is a deploy
variable; the settlement subject and method are constants.
Robokassa is retired, not deleted: the direct rail falls back to it when no
YooKassa shop is configured and no deployment sets its credentials, so reviving
it is a credentials change rather than a code change. Its variables are removed
from compose, .env.example, write-prod-env.sh and the three workflows, and
recorded in backend/internal/robokassa/README.md together with the cabinet
configuration and the revival steps. Ledger rows keep `provider = 'robokassa'`;
that literal is load-bearing for the idempotency index.
No migration and no wire change: `orders.provider_payment_id` already existed,
and the client is rail-agnostic.
Decisions D47-D51 (revising D41) and stage E12 are baked into the docs.
111 lines
4.4 KiB
Go
111 lines
4.4 KiB
Go
// Package robokassa builds and verifies Robokassa direct-rail (RUB) payments: it forms the signed
|
|
// hosted-payment URL a client is sent to, and verifies the Result-URL server callback that credits
|
|
// an order. It is pure provider glue — no database, no payments-domain coupling — so the payments
|
|
// domain stays provider-agnostic and this layer is unit-testable in isolation.
|
|
//
|
|
// This rail is RETIRED: YooKassa settles the direct rail now, and no deployment sets these
|
|
// credentials, so the shop set is empty and the rail is dormant. It stays wired as the fallback the
|
|
// direct rail resolves to when YooKassa has no shop, which makes reviving it a credentials change
|
|
// rather than a code change. README.md in this directory records the retired environment variables,
|
|
// the cabinet configuration and how to bring the rail back.
|
|
//
|
|
// The order is threaded through Robokassa's custom-parameter channel as Shp_order=<order id> (echoed
|
|
// back in the callback and bound into the signature), not the numeric InvId, because an order id is
|
|
// a uuid; InvId is sent as 0. Idempotency is therefore keyed on the order id at the credit site.
|
|
// Signatures use SHA-256 (configured to match the shop's technical settings); the shop's test mode
|
|
// is carried by IsTest.
|
|
package robokassa
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"net/url"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// payEndpoint is Robokassa's hosted payment page; the signed query sends the client there.
|
|
const payEndpoint = "https://auth.robokassa.ru/Merchant/Index.aspx"
|
|
|
|
// Config is a Robokassa shop's credentials. Password1 signs the outgoing payment request;
|
|
// Password2 signs (and so verifies) the incoming Result callback. IsTest adds IsTest=1 so the shop's
|
|
// test mode simulates payments without money movement.
|
|
type Config struct {
|
|
MerchantLogin string
|
|
Password1 string
|
|
Password2 string
|
|
IsTest bool
|
|
}
|
|
|
|
// PaymentURL builds the signed hosted-payment URL for an order: amount is the OutSum decimal string
|
|
// (roubles, e.g. "149.00"), description is the human payment purpose. The order id rides as
|
|
// Shp_order and is bound into the SHA-256 signature; InvId is 0 (unused).
|
|
func (c Config) PaymentURL(orderID uuid.UUID, amount, description string) string {
|
|
q := url.Values{}
|
|
q.Set("Shp_order", orderID.String())
|
|
// Signature base: MerchantLogin:OutSum:InvId:Password1[:Shp_key=value...sorted].
|
|
base := c.MerchantLogin + ":" + amount + ":0:" + c.Password1 + shpSuffix(q)
|
|
|
|
q.Set("MerchantLogin", c.MerchantLogin)
|
|
q.Set("OutSum", amount)
|
|
q.Set("InvId", "0")
|
|
q.Set("Description", description)
|
|
q.Set("SignatureValue", sign(base))
|
|
if c.IsTest {
|
|
q.Set("IsTest", "1")
|
|
}
|
|
return payEndpoint + "?" + q.Encode()
|
|
}
|
|
|
|
// VerifyResult verifies a Result-URL callback and extracts the order it credits. It recomputes the
|
|
// SHA-256 signature OutSum:InvId:Password2[:Shp_...sorted] over the callback's own fields and
|
|
// compares it (case-insensitively) with SignatureValue. On success it returns the order id (from
|
|
// Shp_order) and the raw OutSum string the caller re-checks against the order amount; on any
|
|
// missing field, a signature mismatch or an unparseable order id it returns ok=false.
|
|
func (c Config) VerifyResult(v url.Values) (orderID uuid.UUID, outSum string, ok bool) {
|
|
outSum = v.Get("OutSum")
|
|
sig := v.Get("SignatureValue")
|
|
order := v.Get("Shp_order")
|
|
if outSum == "" || sig == "" || order == "" {
|
|
return uuid.Nil, "", false
|
|
}
|
|
// Robokassa signs with the InvId it returns in the callback; recompute with that same value.
|
|
base := outSum + ":" + v.Get("InvId") + ":" + c.Password2 + shpSuffix(v)
|
|
if !strings.EqualFold(sign(base), sig) {
|
|
return uuid.Nil, "", false
|
|
}
|
|
id, err := uuid.Parse(order)
|
|
if err != nil {
|
|
return uuid.Nil, "", false
|
|
}
|
|
return id, outSum, true
|
|
}
|
|
|
|
// shpSuffix renders the Shp_ custom parameters of v as Robokassa binds them into a signature:
|
|
// every Shp_-prefixed key, sorted alphabetically, appended as ":key=value".
|
|
func shpSuffix(v url.Values) string {
|
|
var keys []string
|
|
for k := range v {
|
|
if strings.HasPrefix(k, "Shp_") {
|
|
keys = append(keys, k)
|
|
}
|
|
}
|
|
sort.Strings(keys)
|
|
var b strings.Builder
|
|
for _, k := range keys {
|
|
b.WriteString(":")
|
|
b.WriteString(k)
|
|
b.WriteString("=")
|
|
b.WriteString(v.Get(k))
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// sign returns the lowercase hex SHA-256 of s, the hash Robokassa compares against SignatureValue.
|
|
func sign(s string) string {
|
|
sum := sha256.Sum256([]byte(s))
|
|
return hex.EncodeToString(sum[:])
|
|
}
|