// 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. // // The order is threaded through Robokassa's custom-parameter channel as Shp_order= (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[:]) }