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:
@@ -0,0 +1,104 @@
|
||||
// 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=<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[:])
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package robokassa
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func testCfg() Config {
|
||||
return Config{MerchantLogin: "shop", Password1: "p1", Password2: "p2", IsTest: true}
|
||||
}
|
||||
|
||||
// resultSig computes the Pass2 Result signature Robokassa would send for a callback carrying only
|
||||
// Shp_order — the fixture the verifier must accept.
|
||||
func resultSig(pass2, outSum, invID string, orderID uuid.UUID) string {
|
||||
return sign(outSum + ":" + invID + ":" + pass2 + ":Shp_order=" + orderID.String())
|
||||
}
|
||||
|
||||
func TestPaymentURL(t *testing.T) {
|
||||
cfg := testCfg()
|
||||
id := uuid.New()
|
||||
u, err := url.Parse(cfg.PaymentURL(id, "149.00", "10 chips"))
|
||||
if err != nil {
|
||||
t.Fatalf("parse payment url: %v", err)
|
||||
}
|
||||
q := u.Query()
|
||||
if got := q.Get("OutSum"); got != "149.00" {
|
||||
t.Errorf("OutSum = %q, want 149.00", got)
|
||||
}
|
||||
if got := q.Get("InvId"); got != "0" {
|
||||
t.Errorf("InvId = %q, want 0 (unused)", got)
|
||||
}
|
||||
if got := q.Get("Shp_order"); got != id.String() {
|
||||
t.Errorf("Shp_order = %q, want the order id", got)
|
||||
}
|
||||
if got := q.Get("IsTest"); got != "1" {
|
||||
t.Errorf("IsTest = %q, want 1 (test shop)", got)
|
||||
}
|
||||
want := sign("shop:149.00:0:p1:Shp_order=" + id.String())
|
||||
if got := q.Get("SignatureValue"); got != want {
|
||||
t.Errorf("SignatureValue = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyResult(t *testing.T) {
|
||||
cfg := testCfg()
|
||||
id := uuid.New()
|
||||
|
||||
valid := func() url.Values {
|
||||
v := url.Values{}
|
||||
v.Set("OutSum", "149.00")
|
||||
v.Set("InvId", "0")
|
||||
v.Set("Shp_order", id.String())
|
||||
// Robokassa sends the hash uppercase; the verifier must be case-insensitive.
|
||||
v.Set("SignatureValue", strings.ToUpper(resultSig("p2", "149.00", "0", id)))
|
||||
return v
|
||||
}
|
||||
|
||||
gotID, gotSum, ok := cfg.VerifyResult(valid())
|
||||
if !ok || gotID != id || gotSum != "149.00" {
|
||||
t.Fatalf("VerifyResult(valid) = %v/%q/%v, want %v/149.00/true", gotID, gotSum, ok, id)
|
||||
}
|
||||
|
||||
// A tampered amount breaks the signature.
|
||||
tampered := valid()
|
||||
tampered.Set("OutSum", "1.00")
|
||||
if _, _, ok := cfg.VerifyResult(tampered); ok {
|
||||
t.Error("VerifyResult accepted a tampered amount")
|
||||
}
|
||||
|
||||
// The wrong Password2 (a forged callback) does not verify.
|
||||
wrongPass := Config{MerchantLogin: "shop", Password1: "p1", Password2: "other"}
|
||||
if _, _, ok := wrongPass.VerifyResult(valid()); ok {
|
||||
t.Error("VerifyResult accepted a signature under the wrong password")
|
||||
}
|
||||
|
||||
// A missing Shp_order is rejected (no order to credit).
|
||||
noOrder := valid()
|
||||
noOrder.Del("Shp_order")
|
||||
if _, _, ok := cfg.VerifyResult(noOrder); ok {
|
||||
t.Error("VerifyResult accepted a callback with no order")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user