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.
226 lines
8.6 KiB
Go
226 lines
8.6 KiB
Go
package yookassa
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// capture records what the fake API received, so a test can assert on the request the client built.
|
|
type capture struct {
|
|
method string
|
|
path string
|
|
authUser string
|
|
authPass string
|
|
idempotenceKey string
|
|
body map[string]any
|
|
}
|
|
|
|
// fakeAPI serves one canned response and records the request. It returns a Config already pointed at
|
|
// it, which is how every API-shaped test drives the client without touching the network.
|
|
func fakeAPI(t *testing.T, status int, response string) (Config, *capture) {
|
|
t.Helper()
|
|
got := &capture{}
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
got.method = r.Method
|
|
got.path = r.URL.Path
|
|
got.authUser, got.authPass, _ = r.BasicAuth()
|
|
got.idempotenceKey = r.Header.Get("Idempotence-Key")
|
|
if raw, _ := io.ReadAll(r.Body); len(raw) > 0 {
|
|
_ = json.Unmarshal(raw, &got.body)
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
_, _ = io.WriteString(w, response)
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
return Config{ShopID: "100500", SecretKey: "secret", BaseURL: srv.URL}, got
|
|
}
|
|
|
|
func TestCreatePaymentBuildsTheRequest(t *testing.T) {
|
|
cfg, got := fakeAPI(t, http.StatusOK, `{
|
|
"id":"23d93cac-000f-5000-8000-126628f15141","status":"pending","paid":false,"test":false,
|
|
"amount":{"value":"149.00","currency":"RUB"},
|
|
"confirmation":{"type":"redirect","confirmation_url":"https://yoomoney.ru/pay/abc"},
|
|
"metadata":{"order_id":"0197-order"}}`)
|
|
|
|
amount := Amount{Value: "149.00", Currency: "RUB"}
|
|
p, err := cfg.CreatePayment(context.Background(), PaymentRequest{
|
|
Amount: amount,
|
|
Capture: true,
|
|
Confirmation: Confirmation{Type: ConfirmationRedirect, ReturnURL: "https://example.test/pay/yookassa/return"},
|
|
Description: "10 chips",
|
|
Metadata: map[string]string{MetadataOrderID: "0197-order"},
|
|
Receipt: SingleItemReceipt("buyer@example.test", "10 chips", amount, VatCodeNone),
|
|
}, "0197-order")
|
|
if err != nil {
|
|
t.Fatalf("create payment: %v", err)
|
|
}
|
|
|
|
if got.method != http.MethodPost || got.path != "/payments" {
|
|
t.Errorf("request = %s %s, want POST /payments", got.method, got.path)
|
|
}
|
|
if got.authUser != "100500" || got.authPass != "secret" {
|
|
t.Errorf("basic auth = %q:%q, want the shop id and secret key", got.authUser, got.authPass)
|
|
}
|
|
// The idempotence key is what makes a retried create return the original payment instead of
|
|
// minting a second one for the same order.
|
|
if got.idempotenceKey != "0197-order" {
|
|
t.Errorf("Idempotence-Key = %q, want the order id", got.idempotenceKey)
|
|
}
|
|
if got.body["capture"] != true {
|
|
t.Errorf("capture = %v, want true (single-stage payment)", got.body["capture"])
|
|
}
|
|
conf, _ := got.body["confirmation"].(map[string]any)
|
|
if conf["type"] != ConfirmationRedirect || conf["return_url"] != "https://example.test/pay/yookassa/return" {
|
|
t.Errorf("confirmation = %v, want a redirect with the return url", conf)
|
|
}
|
|
meta, _ := got.body["metadata"].(map[string]any)
|
|
if meta[MetadataOrderID] != "0197-order" {
|
|
t.Errorf("metadata = %v, want the order id threaded through", meta)
|
|
}
|
|
receipt, _ := got.body["receipt"].(map[string]any)
|
|
customer, _ := receipt["customer"].(map[string]any)
|
|
if customer["email"] != "buyer@example.test" {
|
|
t.Errorf("receipt customer = %v, want the buyer email", customer)
|
|
}
|
|
items, _ := receipt["items"].([]any)
|
|
if len(items) != 1 {
|
|
t.Fatalf("receipt items = %d, want 1", len(items))
|
|
}
|
|
item, _ := items[0].(map[string]any)
|
|
if item["vat_code"] != float64(VatCodeNone) || item["payment_subject"] != PaymentSubjectService || item["payment_mode"] != PaymentModeFullPayment {
|
|
t.Errorf("receipt item fiscal attributes = %v", item)
|
|
}
|
|
|
|
if p.Confirmation.ConfirmationURL != "https://yoomoney.ru/pay/abc" {
|
|
t.Errorf("confirmation url = %q, want the hosted payment page", p.Confirmation.ConfirmationURL)
|
|
}
|
|
if p.OrderID() != "0197-order" {
|
|
t.Errorf("OrderID() = %q, want the order the payment funds", p.OrderID())
|
|
}
|
|
}
|
|
|
|
func TestGetPaymentReadsTheObject(t *testing.T) {
|
|
cfg, got := fakeAPI(t, http.StatusOK, `{
|
|
"id":"pay-1","status":"succeeded","paid":true,"test":true,
|
|
"amount":{"value":"149.00","currency":"RUB"},
|
|
"recipient":{"account_id":"100500","gateway_id":"100700"},
|
|
"metadata":{"order_id":"0197-order"}}`)
|
|
|
|
p, err := cfg.GetPayment(context.Background(), "pay-1")
|
|
if err != nil {
|
|
t.Fatalf("get payment: %v", err)
|
|
}
|
|
if got.method != http.MethodGet || got.path != "/payments/pay-1" {
|
|
t.Errorf("request = %s %s, want GET /payments/pay-1", got.method, got.path)
|
|
}
|
|
// A read is idempotent by nature, so no key is sent.
|
|
if got.idempotenceKey != "" {
|
|
t.Errorf("Idempotence-Key = %q, want none on a GET", got.idempotenceKey)
|
|
}
|
|
if p.Status != StatusSucceeded || !p.Paid {
|
|
t.Errorf("payment = %s paid=%v, want succeeded and paid", p.Status, p.Paid)
|
|
}
|
|
if !p.Test {
|
|
t.Error("Test = false, want true — a test-shop payment must be distinguishable from a live one")
|
|
}
|
|
if p.Recipient.AccountID != "100500" {
|
|
t.Errorf("recipient.account_id = %q, want the shop id", p.Recipient.AccountID)
|
|
}
|
|
}
|
|
|
|
func TestCreateRefundBuildsTheRequest(t *testing.T) {
|
|
cfg, got := fakeAPI(t, http.StatusOK, `{
|
|
"id":"refund-1","status":"succeeded","payment_id":"pay-1",
|
|
"amount":{"value":"149.00","currency":"RUB"}}`)
|
|
|
|
amount := Amount{Value: "149.00", Currency: "RUB"}
|
|
r, err := cfg.CreateRefund(context.Background(), RefundRequest{
|
|
PaymentID: "pay-1",
|
|
Amount: amount,
|
|
Receipt: SingleItemReceipt("buyer@example.test", "10 chips", amount, VatCodeNone),
|
|
}, "0197-order")
|
|
if err != nil {
|
|
t.Fatalf("create refund: %v", err)
|
|
}
|
|
if got.method != http.MethodPost || got.path != "/refunds" {
|
|
t.Errorf("request = %s %s, want POST /refunds", got.method, got.path)
|
|
}
|
|
if got.idempotenceKey != "0197-order" {
|
|
t.Errorf("Idempotence-Key = %q, want the order id (guards a double refund)", got.idempotenceKey)
|
|
}
|
|
if got.body["payment_id"] != "pay-1" {
|
|
t.Errorf("payment_id = %v, want the refunded payment", got.body["payment_id"])
|
|
}
|
|
if _, ok := got.body["receipt"]; !ok {
|
|
t.Error("refund carries no receipt — a refund receipt is required under «Чеки от ЮKassa»")
|
|
}
|
|
// The refund id is distinct from the payment id: the ledger keys the refund row on it, so the
|
|
// fund and refund rows can coexist under the same partial-unique index.
|
|
if r.ID != "refund-1" || r.PaymentID != "pay-1" {
|
|
t.Errorf("refund = %+v, want id refund-1 for payment pay-1", r)
|
|
}
|
|
}
|
|
|
|
func TestAPIErrorClassifiesRetryability(t *testing.T) {
|
|
cfg, _ := fakeAPI(t, http.StatusBadRequest,
|
|
`{"type":"error","id":"err-1","code":"invalid_request","description":"Receipt item is invalid","parameter":"receipt.items"}`)
|
|
|
|
_, err := cfg.GetPayment(context.Background(), "pay-1")
|
|
var apiErr *APIError
|
|
if !errors.As(err, &apiErr) {
|
|
t.Fatalf("error = %v, want an *APIError", err)
|
|
}
|
|
if apiErr.StatusCode != http.StatusBadRequest {
|
|
t.Errorf("status = %d, want 400", apiErr.StatusCode)
|
|
}
|
|
// The offending parameter is what makes a malformed receipt diagnosable rather than a mystery.
|
|
if !strings.Contains(apiErr.Error(), "receipt.items") {
|
|
t.Errorf("error text %q does not name the offending parameter", apiErr.Error())
|
|
}
|
|
if apiErr.Retryable() {
|
|
t.Error("a 400 reported as retryable; a permanent rejection must not be re-delivered")
|
|
}
|
|
|
|
for _, status := range []int{http.StatusTooManyRequests, http.StatusInternalServerError} {
|
|
cfg, _ := fakeAPI(t, status, `{"type":"error","description":"try later"}`)
|
|
_, err := cfg.GetPayment(context.Background(), "pay-1")
|
|
var e *APIError
|
|
if !errors.As(err, &e) || !e.Retryable() {
|
|
t.Errorf("status %d: error = %v, want a retryable *APIError", status, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestUnconfiguredShopMakesNoCall(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
|
t.Error("an unconfigured shop reached the API")
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
|
|
for name, cfg := range map[string]Config{
|
|
"no credentials": {BaseURL: srv.URL},
|
|
"no secret key": {ShopID: "100500", BaseURL: srv.URL},
|
|
"no shop id": {SecretKey: "secret", BaseURL: srv.URL},
|
|
} {
|
|
if _, err := cfg.GetPayment(context.Background(), "pay-1"); !errors.Is(err, ErrUnconfigured) {
|
|
t.Errorf("%s: error = %v, want ErrUnconfigured", name, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestEndpointDefaultsToTheProductionAPI(t *testing.T) {
|
|
if got := (Config{}).endpoint(); got != DefaultBaseURL {
|
|
t.Errorf("endpoint = %q, want %q", got, DefaultBaseURL)
|
|
}
|
|
if got := (Config{BaseURL: "https://api.test/v3/"}).endpoint(); got != "https://api.test/v3" {
|
|
t.Errorf("endpoint = %q, want the trailing slash trimmed", got)
|
|
}
|
|
}
|