7860efce48
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.
86 lines
2.5 KiB
Go
86 lines
2.5 KiB
Go
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")
|
|
}
|
|
}
|