e3961fe4ca
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 22s
CI / ui (pull_request) Successful in 1m16s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 1s
CI / deploy (pull_request) Successful in 2m28s
Refunding from /_gm reported "Already refunded" for a refund that had just succeeded, which reads as "you clicked twice". The two recording paths race. YooKassa fires refund.succeeded the moment POST /v3/refunds returns, so the notification handler often writes the reversal before the console's own write lands. Both name the same refund id, so the ledger's idempotency index rejects the second — which is the mechanism working exactly as intended: on the contour the money moved once, one refund row was written, the balance is right and no abuse flag was raised. Only the message was wrong about why. The console now reports success on that path, and the notification's log line no longer claims the refund was "issued outside the console" when it may well have come from it. Covered by an integration test that lands the notification first and then refunds from the console, asserting the operator is told it succeeded and that exactly one refund row exists.
867 lines
38 KiB
Go
867 lines
38 KiB
Go
//go:build integration
|
|
|
|
package inttest
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"github.com/google/uuid"
|
|
"go.uber.org/zap"
|
|
|
|
"scrabble/backend/internal/account"
|
|
"scrabble/backend/internal/payments"
|
|
"scrabble/backend/internal/server"
|
|
"scrabble/backend/internal/yookassa"
|
|
)
|
|
|
|
// The test shop the fake API answers for. IsTest is true throughout, matching a YooKassa test shop.
|
|
const (
|
|
ykShopID = "100500"
|
|
ykSecretKey = "test_secret"
|
|
// ykSenderIP is inside YooKassa's published notification ranges; the intake rejects anything else.
|
|
ykSenderIP = "185.71.76.1"
|
|
)
|
|
|
|
// fakeYooKassa is a stand-in for the YooKassa API. Tests set the payment it reports and whether a
|
|
// refund succeeds, then assert on what the intake did with that answer.
|
|
type fakeYooKassa struct {
|
|
mu sync.Mutex
|
|
// payments answers GET /payments/{id}; a missing id is a 404, as it is for a payment we never made.
|
|
payments map[string]yookassa.Payment
|
|
// refundFails makes POST /refunds answer 500, standing in for a provider that did not move money.
|
|
refundFails bool
|
|
// refundStatus is the status a created refund reports; empty means succeeded.
|
|
refundStatus string
|
|
// refundCalls counts the refunds actually requested.
|
|
refundCalls int
|
|
// paymentGets counts the confirming reads, so a test can assert a forged notification never
|
|
// reached the provider at all.
|
|
paymentGets int
|
|
// refunds answers GET /refunds/{id}; a missing id is a 404.
|
|
refunds map[string]yookassa.Refund
|
|
// createdIdempotenceKey records the key sent on the last create-payment call.
|
|
createdIdempotenceKey string
|
|
// lastReceipt records the receipt object of the last create-payment call.
|
|
lastReceipt map[string]any
|
|
}
|
|
|
|
// newFakeYooKassa starts the fake API and returns it with a shop config pointed at it.
|
|
func newFakeYooKassa(t *testing.T) (*fakeYooKassa, yookassa.Config) {
|
|
t.Helper()
|
|
f := &fakeYooKassa{payments: map[string]yookassa.Payment{}, refunds: map[string]yookassa.Refund{}}
|
|
srv := httptest.NewServer(http.HandlerFunc(f.serve))
|
|
t.Cleanup(srv.Close)
|
|
return f, yookassa.Config{ShopID: ykShopID, SecretKey: ykSecretKey, IsTest: true, BaseURL: srv.URL}
|
|
}
|
|
|
|
func (f *fakeYooKassa) serve(w http.ResponseWriter, r *http.Request) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
w.Header().Set("Content-Type", "application/json")
|
|
switch {
|
|
case r.Method == http.MethodPost && r.URL.Path == "/payments":
|
|
var body struct {
|
|
Amount yookassa.Amount `json:"amount"`
|
|
Metadata map[string]string `json:"metadata"`
|
|
Receipt map[string]any `json:"receipt"`
|
|
}
|
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
|
f.createdIdempotenceKey = r.Header.Get("Idempotence-Key")
|
|
f.lastReceipt = body.Receipt
|
|
id := "pay-" + body.Metadata[yookassa.MetadataOrderID]
|
|
p := yookassa.Payment{
|
|
ID: id, Status: yookassa.StatusPending, Test: true, Amount: body.Amount,
|
|
Confirmation: yookassa.Confirmation{Type: yookassa.ConfirmationRedirect, ConfirmationURL: "https://yoomoney.test/pay/" + id},
|
|
Metadata: body.Metadata,
|
|
Recipient: yookassa.Recipient{AccountID: ykShopID},
|
|
}
|
|
f.payments[id] = p
|
|
_ = json.NewEncoder(w).Encode(p)
|
|
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/payments/"):
|
|
f.paymentGets++
|
|
p, ok := f.payments[strings.TrimPrefix(r.URL.Path, "/payments/")]
|
|
if !ok {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
_, _ = w.Write([]byte(`{"type":"error","description":"not found"}`))
|
|
return
|
|
}
|
|
_ = json.NewEncoder(w).Encode(p)
|
|
case r.Method == http.MethodPost && r.URL.Path == "/refunds":
|
|
if f.refundFails {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
_, _ = w.Write([]byte(`{"type":"error","description":"acquirer unavailable"}`))
|
|
return
|
|
}
|
|
f.refundCalls++
|
|
var body yookassa.RefundRequest
|
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
|
status := f.refundStatus
|
|
if status == "" {
|
|
status = yookassa.StatusSucceeded
|
|
}
|
|
// The ledger dedupes refunds on (provider, refund id) across the whole database, so the id has
|
|
// to be unique per payment or one test's refund looks like another's duplicate.
|
|
refund := yookassa.Refund{ID: "refund-" + body.PaymentID, Status: status, PaymentID: body.PaymentID, Amount: body.Amount}
|
|
f.refunds[refund.ID] = refund
|
|
_ = json.NewEncoder(w).Encode(refund)
|
|
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/refunds/"):
|
|
refund, ok := f.refunds[strings.TrimPrefix(r.URL.Path, "/refunds/")]
|
|
if !ok {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
_, _ = w.Write([]byte(`{"type":"error","description":"not found"}`))
|
|
return
|
|
}
|
|
_ = json.NewEncoder(w).Encode(refund)
|
|
default:
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}
|
|
}
|
|
|
|
// setRefund publishes a refund the API will report — how a test stands up a refund that was issued
|
|
// somewhere other than through our own API, such as in the merchant cabinet.
|
|
func (f *fakeYooKassa) setRefund(id string, refund yookassa.Refund) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
refund.ID = id
|
|
f.refunds[id] = refund
|
|
}
|
|
|
|
// setPayment overwrites what the API reports for a payment — how a test says "the money did (or did
|
|
// not) really move", independently of what a notification claims.
|
|
func (f *fakeYooKassa) setPayment(id string, mutate func(p *yookassa.Payment)) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
p := f.payments[id]
|
|
p.ID = id
|
|
mutate(&p)
|
|
f.payments[id] = p
|
|
}
|
|
|
|
// yookassaServer builds a backend server whose direct rail is the fake YooKassa shop.
|
|
//
|
|
// The suite shares one database, and the kill-switch test leaves the direct rail disabled, so the
|
|
// rail status is reset first: these tests are about the provider, not about ops availability, and
|
|
// fail-open means an absent row is an enabled rail.
|
|
func yookassaServer(t *testing.T, shop yookassa.Config) (*server.Server, *payments.Service) {
|
|
// Receipts off, as in production: the merchant is outside 54-ФЗ.
|
|
return yookassaServerWithVat(t, shop, yookassa.VatCodeOff)
|
|
}
|
|
|
|
// yookassaServerWithVat is yookassaServer with an explicit VAT rate code, for exercising the fiscal
|
|
// receipt path that stays dormant until a rate is configured.
|
|
func yookassaServerWithVat(t *testing.T, shop yookassa.Config, vatCode int) (*server.Server, *payments.Service) {
|
|
t.Helper()
|
|
if _, err := testDB.ExecContext(context.Background(),
|
|
`DELETE FROM payments.rail_status WHERE rail LIKE 'direct%'`); err != nil {
|
|
t.Fatalf("reset direct rail status: %v", err)
|
|
}
|
|
paySvc := newPaymentsService()
|
|
srv := server.New(":0", server.Deps{
|
|
Logger: zap.NewNop(),
|
|
Accounts: account.NewStore(testDB),
|
|
Games: newGameService(),
|
|
Registry: testRegistry,
|
|
DictDir: dictDir(),
|
|
Payments: paySvc,
|
|
YooKassa: yookassa.Shops{yookassa.ChannelWeb: shop},
|
|
YooKassaVatCode: vatCode,
|
|
PublicBaseURL: "https://scrabble.test",
|
|
})
|
|
return srv, paySvc
|
|
}
|
|
|
|
// postYooKassaNotify posts a notification body to the intake as the given sender, and reports the status code.
|
|
func postYooKassaNotify(t *testing.T, srv *server.Server, senderIP, event, paymentID, orderID string) int {
|
|
t.Helper()
|
|
body := fmt.Sprintf(`{"type":"notification","event":%q,"object":{"id":%q,"status":"succeeded",
|
|
"recipient":{"account_id":%q},"metadata":{"order_id":%q}}}`, event, paymentID, ykShopID, orderID)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/internal/payments/yookassa/notify", strings.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("X-Forwarded-For", senderIP)
|
|
rec := httptest.NewRecorder()
|
|
srv.Handler().ServeHTTP(rec, req)
|
|
return rec.Code
|
|
}
|
|
|
|
// seedYooKassaOrder opens a paid-for-real order: a pending order with the provider payment id
|
|
// attached, exactly as the order path leaves it once YooKassa has minted the payment.
|
|
func seedYooKassaOrder(t *testing.T, f *fakeYooKassa, pay *payments.Service, acc uuid.UUID) (uuid.UUID, string) {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
prod := seedPackProduct(t, 100, methodPrice{method: "direct", currency: "RUB", amount: 14900})
|
|
res, err := pay.CreateOrder(ctx, acc, payments.NewContext("direct", "web"), []payments.Source{payments.SourceDirect}, prod, "yookassa")
|
|
if err != nil {
|
|
t.Fatalf("create order: %v", err)
|
|
}
|
|
paymentID := "pay-" + res.OrderID.String()
|
|
f.setPayment(paymentID, func(p *yookassa.Payment) {
|
|
p.Status = yookassa.StatusSucceeded
|
|
p.Paid = true
|
|
p.Test = true
|
|
p.Amount = yookassa.Amount{Value: "149.00", Currency: "RUB"}
|
|
p.Metadata = map[string]string{yookassa.MetadataOrderID: res.OrderID.String()}
|
|
p.Recipient = yookassa.Recipient{AccountID: ykShopID}
|
|
})
|
|
if err := pay.AttachProviderPayment(ctx, res.OrderID, "yookassa", paymentID); err != nil {
|
|
t.Fatalf("attach provider payment: %v", err)
|
|
}
|
|
return res.OrderID, paymentID
|
|
}
|
|
|
|
// TestYooKassaNotifyCreditsOnce drives the crediting path: a succeeded notification credits the
|
|
// order exactly once, and a redelivery credits nothing more.
|
|
func TestYooKassaNotifyCreditsOnce(t *testing.T) {
|
|
f, shop := newFakeYooKassa(t)
|
|
srv, pay := yookassaServer(t, shop)
|
|
acc := provisionAccount(t)
|
|
orderID, paymentID := seedYooKassaOrder(t, f, pay, acc)
|
|
|
|
if code := postYooKassaNotify(t, srv, ykSenderIP, yookassa.EventPaymentSucceeded, paymentID, orderID.String()); code != http.StatusOK {
|
|
t.Fatalf("notify = %d, want 200", code)
|
|
}
|
|
if got := readBalance(t, acc, "direct"); got != 100 {
|
|
t.Errorf("balance = %d, want 100", got)
|
|
}
|
|
if orderStatus(t, orderID) != "paid" {
|
|
t.Errorf("order status = %s, want paid", orderStatus(t, orderID))
|
|
}
|
|
|
|
// YooKassa redelivers until it gets a 200; a redelivery must credit nothing and still answer 200.
|
|
if code := postYooKassaNotify(t, srv, ykSenderIP, yookassa.EventPaymentSucceeded, paymentID, orderID.String()); code != http.StatusOK {
|
|
t.Fatalf("redelivered notify = %d, want 200", code)
|
|
}
|
|
if got := readBalance(t, acc, "direct"); got != 100 {
|
|
t.Errorf("balance after redelivery = %d, want 100 (credited once)", got)
|
|
}
|
|
if ledgerRows(t, acc, "fund") != 1 {
|
|
t.Errorf("fund ledger rows = %d, want 1", ledgerRows(t, acc, "fund"))
|
|
}
|
|
}
|
|
|
|
// TestYooKassaNotifyIsNotEvidence is the security property of the whole rail: notifications are
|
|
// unsigned, so a fabricated "succeeded" credits nothing when the confirming read says otherwise.
|
|
func TestYooKassaNotifyIsNotEvidence(t *testing.T) {
|
|
f, shop := newFakeYooKassa(t)
|
|
srv, pay := yookassaServer(t, shop)
|
|
acc := provisionAccount(t)
|
|
orderID, paymentID := seedYooKassaOrder(t, f, pay, acc)
|
|
|
|
// The provider says the payment never completed, whatever the notification claims.
|
|
f.setPayment(paymentID, func(p *yookassa.Payment) { p.Status = yookassa.StatusPending; p.Paid = false })
|
|
if code := postYooKassaNotify(t, srv, ykSenderIP, yookassa.EventPaymentSucceeded, paymentID, orderID.String()); code != http.StatusOK {
|
|
t.Fatalf("notify = %d, want 200", code)
|
|
}
|
|
if got := readBalance(t, acc, "direct"); got != 0 {
|
|
t.Errorf("balance = %d, want 0 — a forged notification credited chips", got)
|
|
}
|
|
|
|
// A payment we never made: the confirming read 404s, so there is nothing to credit.
|
|
if code := postYooKassaNotify(t, srv, ykSenderIP, yookassa.EventPaymentSucceeded, "pay-invented", orderID.String()); code != http.StatusOK {
|
|
t.Fatalf("notify for an unknown payment = %d, want 200", code)
|
|
}
|
|
if got := readBalance(t, acc, "direct"); got != 0 {
|
|
t.Errorf("balance = %d, want 0 — an invented payment credited chips", got)
|
|
}
|
|
}
|
|
|
|
// TestYooKassaNotifyForAnUnknownOrderCostsNoProviderCall pins what actually keeps a forger from
|
|
// using this endpoint to drive traffic at the provider on our behalf. The sender address is not
|
|
// checked — it cannot be, in a deployment that sees only its own tunnel — so the guard is that the
|
|
// order is resolved from the notification first: an id matching no order costs one indexed read and
|
|
// stops there. Guessing a live order id means guessing a uuid.
|
|
func TestYooKassaNotifyForAnUnknownOrderCostsNoProviderCall(t *testing.T) {
|
|
f, shop := newFakeYooKassa(t)
|
|
srv, _ := yookassaServer(t, shop)
|
|
|
|
before := f.paymentGets
|
|
if code := postYooKassaNotify(t, srv, ykSenderIP, yookassa.EventPaymentSucceeded, "pay-forged", uuid.NewString()); code != http.StatusOK {
|
|
t.Fatalf("notify = %d, want 200", code)
|
|
}
|
|
if f.paymentGets != before {
|
|
t.Errorf("confirming reads = %d, want %d — a forged notification reached the provider", f.paymentGets, before)
|
|
}
|
|
}
|
|
|
|
// TestYooKassaNotifyIsAcceptedFromAnyAddress: the contour (and any deployment behind a tunnel) sees
|
|
// only its own internal address as the sender, so a genuine notification must still be honoured.
|
|
func TestYooKassaNotifyIsAcceptedFromAnyAddress(t *testing.T) {
|
|
f, shop := newFakeYooKassa(t)
|
|
srv, pay := yookassaServer(t, shop)
|
|
acc := provisionAccount(t)
|
|
orderID, paymentID := seedYooKassaOrder(t, f, pay, acc)
|
|
|
|
if code := postYooKassaNotify(t, srv, "10.77.0.1", yookassa.EventPaymentSucceeded, paymentID, orderID.String()); code != http.StatusOK {
|
|
t.Fatalf("notify = %d, want 200", code)
|
|
}
|
|
if got := readBalance(t, acc, "direct"); got != 100 {
|
|
t.Errorf("balance = %d, want 100 — a genuine notification was refused on its source address", got)
|
|
}
|
|
}
|
|
|
|
// TestYooKassaNotifyRejectsLiveOnTestShop is the guard that keeps play money out of a live ledger and
|
|
// real money out of a test one: a payment whose test flag disagrees with the shop's is not credited.
|
|
func TestYooKassaNotifyRejectsLiveOnTestShop(t *testing.T) {
|
|
f, shop := newFakeYooKassa(t)
|
|
srv, pay := yookassaServer(t, shop) // the shop is a test shop
|
|
acc := provisionAccount(t)
|
|
orderID, paymentID := seedYooKassaOrder(t, f, pay, acc)
|
|
|
|
f.setPayment(paymentID, func(p *yookassa.Payment) { p.Test = false }) // a live payment
|
|
if code := postYooKassaNotify(t, srv, ykSenderIP, yookassa.EventPaymentSucceeded, paymentID, orderID.String()); code != http.StatusOK {
|
|
t.Fatalf("notify = %d, want 200", code)
|
|
}
|
|
if got := readBalance(t, acc, "direct"); got != 0 {
|
|
t.Errorf("balance = %d, want 0 — a live payment credited against test credentials", got)
|
|
}
|
|
}
|
|
|
|
// TestYooKassaCanceledRecordsFailure checks an active decline records a failed payment event and
|
|
// credits nothing, so the customer learns the attempt did not go through.
|
|
func TestYooKassaCanceledRecordsFailure(t *testing.T) {
|
|
f, shop := newFakeYooKassa(t)
|
|
srv, pay := yookassaServer(t, shop)
|
|
acc := provisionAccount(t)
|
|
orderID, paymentID := seedYooKassaOrder(t, f, pay, acc)
|
|
|
|
f.setPayment(paymentID, func(p *yookassa.Payment) {
|
|
p.Status = yookassa.StatusCanceled
|
|
p.Paid = false
|
|
p.CancellationDetails = &yookassa.Cancellation{Party: "payment_network", Reason: "insufficient_funds"}
|
|
})
|
|
if code := postYooKassaNotify(t, srv, ykSenderIP, yookassa.EventPaymentCanceled, paymentID, orderID.String()); code != http.StatusOK {
|
|
t.Fatalf("notify = %d, want 200", code)
|
|
}
|
|
if got := readBalance(t, acc, "direct"); got != 0 {
|
|
t.Errorf("balance = %d, want 0 — a canceled payment credited chips", got)
|
|
}
|
|
if orderStatus(t, orderID) != "pending" {
|
|
t.Errorf("order status = %s, want pending (a decline does not settle the order)", orderStatus(t, orderID))
|
|
}
|
|
evs, err := pay.UndispatchedEvents(context.Background(), 50)
|
|
if err != nil {
|
|
t.Fatalf("read events: %v", err)
|
|
}
|
|
found := false
|
|
for _, e := range evs {
|
|
if e.AccountID == acc && e.Type == "failed" {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Error("no failed payment event recorded for a declined payment")
|
|
}
|
|
}
|
|
|
|
// TestYooKassaReconcileCreditsLostNotification is the safety net: when no notification ever arrives,
|
|
// the expiry-time check asks the provider and credits an order that was in fact paid.
|
|
func TestYooKassaReconcileCreditsLostNotification(t *testing.T) {
|
|
f, shop := newFakeYooKassa(t)
|
|
srv, pay := yookassaServer(t, shop)
|
|
acc := provisionAccount(t)
|
|
orderID, _ := seedYooKassaOrder(t, f, pay, acc)
|
|
|
|
// Age the order just past the re-check threshold — minutes, not the order's full lifetime — with
|
|
// no notification ever delivered.
|
|
if _, err := testDB.ExecContext(context.Background(),
|
|
`UPDATE payments.orders SET created_at = now() - interval '2 minutes' WHERE order_id=$1`, orderID); err != nil {
|
|
t.Fatalf("age order: %v", err)
|
|
}
|
|
if n := srv.ReconcileYooKassaOrders(context.Background()); n != 1 {
|
|
t.Fatalf("reconciled %d orders, want 1", n)
|
|
}
|
|
if got := readBalance(t, acc, "direct"); got != 100 {
|
|
t.Errorf("balance = %d, want 100 — the safety net did not credit a paid order", got)
|
|
}
|
|
if orderStatus(t, orderID) != "paid" {
|
|
t.Errorf("order status = %s, want paid", orderStatus(t, orderID))
|
|
}
|
|
// A second sweep finds nothing left to do — the order is no longer pending.
|
|
if n := srv.ReconcileYooKassaOrders(context.Background()); n != 0 {
|
|
t.Errorf("second sweep credited %d orders, want 0", n)
|
|
}
|
|
}
|
|
|
|
// TestYooKassaReconcileSkipsFreshOrders keeps the sweep off an order the customer is still paying
|
|
// for: it re-checks only orders older than ReconcileAfter, so opening a payment page does not start
|
|
// polling the provider straight away.
|
|
func TestYooKassaReconcileSkipsFreshOrders(t *testing.T) {
|
|
f, shop := newFakeYooKassa(t)
|
|
srv, pay := yookassaServer(t, shop)
|
|
acc := provisionAccount(t)
|
|
seedYooKassaOrder(t, f, pay, acc) // just created
|
|
|
|
before := f.paymentGets
|
|
if n := srv.ReconcileYooKassaOrders(context.Background()); n != 0 {
|
|
t.Errorf("reconciled %d orders, want 0 (the order is seconds old)", n)
|
|
}
|
|
if f.paymentGets != before {
|
|
t.Errorf("confirming reads = %d, want %d — a fresh order was polled", f.paymentGets, before)
|
|
}
|
|
if got := readBalance(t, acc, "direct"); got != 0 {
|
|
t.Errorf("balance = %d, want 0", got)
|
|
}
|
|
}
|
|
|
|
// TestYooKassaReconcileLeavesUnpaidOrders checks the sweep does not invent a credit for an order the
|
|
// customer abandoned.
|
|
func TestYooKassaReconcileLeavesUnpaidOrders(t *testing.T) {
|
|
f, shop := newFakeYooKassa(t)
|
|
srv, pay := yookassaServer(t, shop)
|
|
acc := provisionAccount(t)
|
|
orderID, paymentID := seedYooKassaOrder(t, f, pay, acc)
|
|
f.setPayment(paymentID, func(p *yookassa.Payment) { p.Status = yookassa.StatusCanceled; p.Paid = false })
|
|
|
|
if _, err := testDB.ExecContext(context.Background(),
|
|
`UPDATE payments.orders SET created_at = now() - interval '2 days' WHERE order_id=$1`, orderID); err != nil {
|
|
t.Fatalf("age order: %v", err)
|
|
}
|
|
if n := srv.ReconcileYooKassaOrders(context.Background()); n != 0 {
|
|
t.Errorf("reconciled %d orders, want 0 (the payment was never completed)", n)
|
|
}
|
|
if got := readBalance(t, acc, "direct"); got != 0 {
|
|
t.Errorf("balance = %d, want 0", got)
|
|
}
|
|
}
|
|
|
|
// TestYooKassaConsoleRefundMovesMoneyThenRecords drives the operator refund end to end: the provider
|
|
// refund is requested first and the ledger records the provider's own refund id.
|
|
func TestYooKassaConsoleRefundMovesMoneyThenRecords(t *testing.T) {
|
|
f, shop := newFakeYooKassa(t)
|
|
srv, pay := yookassaServer(t, shop)
|
|
acc := provisionAccount(t)
|
|
orderID, paymentID := seedYooKassaOrder(t, f, pay, acc)
|
|
if code := postYooKassaNotify(t, srv, ykSenderIP, yookassa.EventPaymentSucceeded, paymentID, orderID.String()); code != http.StatusOK {
|
|
t.Fatalf("notify = %d, want 200", code)
|
|
}
|
|
|
|
h := srv.Handler()
|
|
base := "http://admin.test/_gm/users/" + acc.String()
|
|
code, body := consoleDo(h, http.MethodPost, base+"/refund", "order_id="+orderID.String(), "http://admin.test")
|
|
if code != http.StatusOK || !strings.Contains(body, "revoked 100 chips") {
|
|
t.Fatalf("refund = %d, body has 'revoked 100 chips' = %v", code, strings.Contains(body, "revoked 100 chips"))
|
|
}
|
|
if f.refundCalls != 1 {
|
|
t.Errorf("provider refunds requested = %d, want 1", f.refundCalls)
|
|
}
|
|
if got := readBalance(t, acc, "direct"); got != 0 {
|
|
t.Errorf("balance after refund = %d, want 0", got)
|
|
}
|
|
// The ledger records the provider's own refund id, which is what keeps it reconcilable against
|
|
// YooKassa's records — and is distinct from the payment id, so both rows coexist.
|
|
var provider, refundID string
|
|
if err := testDB.QueryRowContext(context.Background(),
|
|
`SELECT provider, provider_payment_id FROM payments.ledger WHERE account_id=$1 AND kind='refund'`,
|
|
acc).Scan(&provider, &refundID); err != nil {
|
|
t.Fatalf("read refund ledger row: %v", err)
|
|
}
|
|
if provider != "yookassa" || refundID != "refund-"+paymentID {
|
|
t.Errorf("refund ledger row = (%s, %s), want (yookassa, refund-%s)", provider, refundID, paymentID)
|
|
}
|
|
}
|
|
|
|
// TestYooKassaRefundRecordsNothingWhenTheProviderFails is the invariant that keeps the ledger honest:
|
|
// if the money did not move, nothing is written.
|
|
func TestYooKassaRefundRecordsNothingWhenTheProviderFails(t *testing.T) {
|
|
f, shop := newFakeYooKassa(t)
|
|
srv, pay := yookassaServer(t, shop)
|
|
acc := provisionAccount(t)
|
|
orderID, paymentID := seedYooKassaOrder(t, f, pay, acc)
|
|
if code := postYooKassaNotify(t, srv, ykSenderIP, yookassa.EventPaymentSucceeded, paymentID, orderID.String()); code != http.StatusOK {
|
|
t.Fatalf("notify = %d, want 200", code)
|
|
}
|
|
|
|
f.refundFails = true
|
|
h := srv.Handler()
|
|
base := "http://admin.test/_gm/users/" + acc.String()
|
|
_, body := consoleDo(h, http.MethodPost, base+"/refund", "order_id="+orderID.String(), "http://admin.test")
|
|
if !strings.Contains(body, "nothing was recorded") {
|
|
t.Errorf("refund failure message = %q, want it to say nothing was recorded", body)
|
|
}
|
|
if got := readBalance(t, acc, "direct"); got != 100 {
|
|
t.Errorf("balance = %d, want 100 — chips were revoked without the money moving", got)
|
|
}
|
|
if ledgerRows(t, acc, "refund") != 0 {
|
|
t.Error("a refund ledger row was written although the provider did not refund")
|
|
}
|
|
}
|
|
|
|
// seedBuyerWithEmail provisions an account with a confirmed email — the D36 anchor a direct purchase
|
|
// requires — and returns the account and the address.
|
|
func seedBuyerWithEmail(t *testing.T) (uuid.UUID, string) {
|
|
t.Helper()
|
|
acc := provisionAccount(t)
|
|
// The full id, not a prefix: account ids are uuid v7, whose leading hex digits are the top bits
|
|
// of a millisecond timestamp and so are identical for accounts made moments apart.
|
|
email := "buyer-" + acc.String() + "@example.test"
|
|
if _, err := testDB.ExecContext(context.Background(),
|
|
`INSERT INTO backend.identities (identity_id, account_id, kind, external_id, confirmed) VALUES ($1,$2,'email',$3,true)`,
|
|
uuid.New(), acc, email); err != nil {
|
|
t.Fatalf("seed email identity: %v", err)
|
|
}
|
|
return acc, email
|
|
}
|
|
|
|
// postWalletOrder opens a purchase over HTTP as the account, on the direct/web platform.
|
|
func postWalletOrder(t *testing.T, srv *server.Server, acc, productID uuid.UUID) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/user/wallet/order",
|
|
strings.NewReader(fmt.Sprintf(`{"product_id":%q}`, productID)))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("X-User-ID", acc.String())
|
|
req.Header.Set("X-Platform", "direct/web")
|
|
rec := httptest.NewRecorder()
|
|
srv.Handler().ServeHTTP(rec, req)
|
|
return rec
|
|
}
|
|
|
|
// TestYooKassaOrderSendsNoReceipt pins the fiscal decision: the merchant is outside 54-ФЗ, so a
|
|
// purchase must carry no receipt at all — the provider registers nothing and the merchant reports
|
|
// each operation itself.
|
|
func TestYooKassaOrderSendsNoReceipt(t *testing.T) {
|
|
f, shop := newFakeYooKassa(t)
|
|
srv, _ := yookassaServer(t, shop)
|
|
acc, _ := seedBuyerWithEmail(t)
|
|
prod := seedPackProduct(t, 100, methodPrice{method: "direct", currency: "RUB", amount: 14900})
|
|
|
|
rec := postWalletOrder(t, srv, acc, prod)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("order = %d, want 200: %s", rec.Code, rec.Body.String())
|
|
}
|
|
if f.lastReceipt != nil {
|
|
t.Errorf("the purchase carried a receipt (%v); receipts are off outside 54-ФЗ", f.lastReceipt)
|
|
}
|
|
}
|
|
|
|
// TestYooKassaReceiptTurnsOnWithAVatCode proves the dormant fiscal path still works, so a lost НПД
|
|
// regime is a deploy-variable change rather than a code change.
|
|
func TestYooKassaReceiptTurnsOnWithAVatCode(t *testing.T) {
|
|
f, shop := newFakeYooKassa(t)
|
|
srv, _ := yookassaServerWithVat(t, shop, yookassa.VatCodeNone)
|
|
acc, email := seedBuyerWithEmail(t)
|
|
prod := seedPackProduct(t, 100, methodPrice{method: "direct", currency: "RUB", amount: 14900})
|
|
|
|
if rec := postWalletOrder(t, srv, acc, prod); rec.Code != http.StatusOK {
|
|
t.Fatalf("order = %d, want 200: %s", rec.Code, rec.Body.String())
|
|
}
|
|
if f.lastReceipt == nil {
|
|
t.Fatal("no receipt sent although a VAT rate code is configured")
|
|
}
|
|
customer, _ := f.lastReceipt["customer"].(map[string]any)
|
|
if customer["email"] != email {
|
|
t.Errorf("receipt customer = %v, want the confirmed email %q", customer, email)
|
|
}
|
|
items, _ := f.lastReceipt["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(yookassa.VatCodeNone) || item["payment_subject"] != yookassa.PaymentSubjectService {
|
|
t.Errorf("receipt fiscal attributes = %v", item)
|
|
}
|
|
}
|
|
|
|
// TestYooKassaOrderMintsAPayment drives the purchase path over HTTP: the order endpoint calls the
|
|
// provider, threads the order id through, and hands the client the hosted payment page.
|
|
func TestYooKassaOrderMintsAPayment(t *testing.T) {
|
|
f, shop := newFakeYooKassa(t)
|
|
srv, _ := yookassaServer(t, shop)
|
|
acc := provisionAccount(t)
|
|
const email = "buyer@example.test"
|
|
if _, err := testDB.ExecContext(context.Background(),
|
|
`INSERT INTO backend.identities (identity_id, account_id, kind, external_id, confirmed) VALUES ($1,$2,'email',$3,true)`,
|
|
uuid.New(), acc, email); err != nil {
|
|
t.Fatalf("seed email identity: %v", err)
|
|
}
|
|
prod := seedPackProduct(t, 100, methodPrice{method: "direct", currency: "RUB", amount: 14900})
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/user/wallet/order",
|
|
strings.NewReader(fmt.Sprintf(`{"product_id":%q}`, prod)))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("X-User-ID", acc.String())
|
|
req.Header.Set("X-Platform", "direct/web")
|
|
rec := httptest.NewRecorder()
|
|
srv.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("order = %d, want 200: %s", rec.Code, rec.Body.String())
|
|
}
|
|
var out struct {
|
|
OrderID string `json:"order_id"`
|
|
RedirectURL string `json:"redirect_url"`
|
|
Rail string `json:"rail"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
|
t.Fatalf("decode order response: %v", err)
|
|
}
|
|
if out.Rail != "yookassa" || !strings.HasPrefix(out.RedirectURL, "https://yoomoney.test/pay/") {
|
|
t.Errorf("order response = %+v, want the yookassa rail and its hosted payment page", out)
|
|
}
|
|
// The order id is both the idempotency key (so a retried create cannot mint a second payment)
|
|
// and the metadata that later resolves a notification to this order.
|
|
if f.createdIdempotenceKey != out.OrderID {
|
|
t.Errorf("Idempotence-Key = %q, want the order id %q", f.createdIdempotenceKey, out.OrderID)
|
|
}
|
|
// The payment id is recorded on the order, which is what the safety net and a refund need.
|
|
orderID, err := uuid.Parse(out.OrderID)
|
|
if err != nil {
|
|
t.Fatalf("parse order id: %v", err)
|
|
}
|
|
var paymentID string
|
|
if err := testDB.QueryRowContext(context.Background(),
|
|
`SELECT provider_payment_id FROM payments.orders WHERE order_id=$1`, orderID).Scan(&paymentID); err != nil {
|
|
t.Fatalf("read order payment id: %v", err)
|
|
}
|
|
if paymentID != "pay-"+out.OrderID {
|
|
t.Errorf("order provider_payment_id = %q, want the minted payment id", paymentID)
|
|
}
|
|
}
|
|
|
|
// TestYooKassaOrderRequiresAnEmailAnchor keeps D36 in force on the new rail: without a confirmed
|
|
// address there is no recovery anchor and nowhere to deliver the fiscal receipt, so no payment is
|
|
// minted at all.
|
|
func TestYooKassaOrderRequiresAnEmailAnchor(t *testing.T) {
|
|
_, shop := newFakeYooKassa(t)
|
|
srv, _ := yookassaServer(t, shop)
|
|
acc := provisionAccount(t) // a telegram identity only — no confirmed email
|
|
prod := seedPackProduct(t, 100, methodPrice{method: "direct", currency: "RUB", amount: 14900})
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/user/wallet/order",
|
|
strings.NewReader(fmt.Sprintf(`{"product_id":%q}`, prod)))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("X-User-ID", acc.String())
|
|
req.Header.Set("X-Platform", "direct/web")
|
|
rec := httptest.NewRecorder()
|
|
srv.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden || !strings.Contains(rec.Body.String(), "email_required") {
|
|
t.Fatalf("order = %d (%s), want 403 email_required", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
// postYooKassaRefundNotify posts a refund notification to the intake and reports the status code.
|
|
func postYooKassaRefundNotify(t *testing.T, srv *server.Server, refundID, paymentID string) int {
|
|
t.Helper()
|
|
body := fmt.Sprintf(`{"type":"notification","event":%q,"object":{"id":%q,"status":"succeeded","payment_id":%q}}`,
|
|
yookassa.EventRefundSucceeded, refundID, paymentID)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/internal/payments/yookassa/notify", strings.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("X-Forwarded-For", ykSenderIP)
|
|
rec := httptest.NewRecorder()
|
|
srv.Handler().ServeHTTP(rec, req)
|
|
return rec.Code
|
|
}
|
|
|
|
// fundedYooKassaOrder seeds an order and credits it, leaving an account with chips to reverse.
|
|
func fundedYooKassaOrder(t *testing.T, f *fakeYooKassa, srv *server.Server, pay *payments.Service, acc uuid.UUID) (uuid.UUID, string) {
|
|
t.Helper()
|
|
orderID, paymentID := seedYooKassaOrder(t, f, pay, acc)
|
|
if code := postYooKassaNotify(t, srv, ykSenderIP, yookassa.EventPaymentSucceeded, paymentID, orderID.String()); code != http.StatusOK {
|
|
t.Fatalf("notify = %d, want 200", code)
|
|
}
|
|
return orderID, paymentID
|
|
}
|
|
|
|
// TestYooKassaCabinetRefundIsReversed is why the refund event is handled at all: an operator can
|
|
// refund in the merchant cabinet, which never passes through our API, so without this the money would
|
|
// go back while the chips stayed credited.
|
|
func TestYooKassaCabinetRefundIsReversed(t *testing.T) {
|
|
f, shop := newFakeYooKassa(t)
|
|
srv, pay := yookassaServer(t, shop)
|
|
acc := provisionAccount(t)
|
|
_, paymentID := fundedYooKassaOrder(t, f, srv, pay, acc)
|
|
if got := readBalance(t, acc, "direct"); got != 100 {
|
|
t.Fatalf("balance before the refund = %d, want 100", got)
|
|
}
|
|
|
|
// The refund exists at the provider, issued outside our API.
|
|
f.setRefund("cabinet-refund-1", yookassa.Refund{
|
|
Status: yookassa.StatusSucceeded, PaymentID: paymentID,
|
|
Amount: yookassa.Amount{Value: "149.00", Currency: "RUB"},
|
|
})
|
|
if code := postYooKassaRefundNotify(t, srv, "cabinet-refund-1", paymentID); code != http.StatusOK {
|
|
t.Fatalf("refund notify = %d, want 200", code)
|
|
}
|
|
if got := readBalance(t, acc, "direct"); got != 0 {
|
|
t.Errorf("balance = %d, want 0 — a cabinet refund did not revoke the chips", got)
|
|
}
|
|
if f.refundCalls != 0 {
|
|
t.Errorf("provider refunds requested = %d, want 0 — the money had already moved", f.refundCalls)
|
|
}
|
|
var provider, refundID string
|
|
if err := testDB.QueryRowContext(context.Background(),
|
|
`SELECT provider, provider_payment_id FROM payments.ledger WHERE account_id=$1 AND kind='refund'`,
|
|
acc).Scan(&provider, &refundID); err != nil {
|
|
t.Fatalf("read refund ledger row: %v", err)
|
|
}
|
|
if provider != "yookassa" || refundID != "cabinet-refund-1" {
|
|
t.Errorf("refund ledger row = (%s, %s), want (yookassa, cabinet-refund-1)", provider, refundID)
|
|
}
|
|
|
|
// A redelivery reverses nothing a second time.
|
|
if code := postYooKassaRefundNotify(t, srv, "cabinet-refund-1", paymentID); code != http.StatusOK {
|
|
t.Fatalf("redelivered refund notify = %d, want 200", code)
|
|
}
|
|
if ledgerRows(t, acc, "refund") != 1 {
|
|
t.Errorf("refund ledger rows = %d, want 1", ledgerRows(t, acc, "refund"))
|
|
}
|
|
}
|
|
|
|
// TestYooKassaRefundNotifyAfterConsoleRefundIsANoOp checks the two refund entry points cannot double
|
|
// up: the event for a refund the console already recorded reverses nothing again.
|
|
func TestYooKassaRefundNotifyAfterConsoleRefundIsANoOp(t *testing.T) {
|
|
f, shop := newFakeYooKassa(t)
|
|
srv, pay := yookassaServer(t, shop)
|
|
acc := provisionAccount(t)
|
|
orderID, paymentID := fundedYooKassaOrder(t, f, srv, pay, acc)
|
|
|
|
base := "http://admin.test/_gm/users/" + acc.String()
|
|
if code, _ := consoleDo(srv.Handler(), http.MethodPost, base+"/refund", "order_id="+orderID.String(), "http://admin.test"); code != http.StatusOK {
|
|
t.Fatalf("console refund = %d, want 200", code)
|
|
}
|
|
if got := readBalance(t, acc, "direct"); got != 0 {
|
|
t.Fatalf("balance after the console refund = %d, want 0", got)
|
|
}
|
|
// YooKassa then notifies about that same refund.
|
|
if code := postYooKassaRefundNotify(t, srv, "refund-"+paymentID, paymentID); code != http.StatusOK {
|
|
t.Fatalf("refund notify = %d, want 200", code)
|
|
}
|
|
if ledgerRows(t, acc, "refund") != 1 {
|
|
t.Errorf("refund ledger rows = %d, want 1 (reversed once)", ledgerRows(t, acc, "refund"))
|
|
}
|
|
if abuse, loss := readRisk(t, acc); abuse || loss != 0 {
|
|
t.Errorf("risk = (abuse %v, loss %d), want none — the second reversal ran anyway", abuse, loss)
|
|
}
|
|
}
|
|
|
|
// TestYooKassaPartialRefundIsLeftToAnOperator: the reversal engine revokes exactly what the pack
|
|
// funded and rejects any other amount, so a partial refund cannot be recorded without guessing how
|
|
// many chips it costs. It must change nothing rather than guess.
|
|
func TestYooKassaPartialRefundIsLeftToAnOperator(t *testing.T) {
|
|
f, shop := newFakeYooKassa(t)
|
|
srv, pay := yookassaServer(t, shop)
|
|
acc := provisionAccount(t)
|
|
_, paymentID := fundedYooKassaOrder(t, f, srv, pay, acc)
|
|
|
|
f.setRefund("partial-1", yookassa.Refund{
|
|
Status: yookassa.StatusSucceeded, PaymentID: paymentID,
|
|
Amount: yookassa.Amount{Value: "50.00", Currency: "RUB"}, // part of the 149.00 order
|
|
})
|
|
if code := postYooKassaRefundNotify(t, srv, "partial-1", paymentID); code != http.StatusOK {
|
|
t.Fatalf("refund notify = %d, want 200", code)
|
|
}
|
|
if got := readBalance(t, acc, "direct"); got != 100 {
|
|
t.Errorf("balance = %d, want 100 — a partial refund revoked chips", got)
|
|
}
|
|
if ledgerRows(t, acc, "refund") != 0 {
|
|
t.Error("a partial refund wrote a refund ledger row")
|
|
}
|
|
}
|
|
|
|
// TestYooKassaRefundNotifyIsNotEvidence: as with a payment, the body only names a refund. One the
|
|
// provider does not confirm reverses nothing.
|
|
func TestYooKassaRefundNotifyIsNotEvidence(t *testing.T) {
|
|
f, shop := newFakeYooKassa(t)
|
|
srv, pay := yookassaServer(t, shop)
|
|
acc := provisionAccount(t)
|
|
_, paymentID := fundedYooKassaOrder(t, f, srv, pay, acc)
|
|
|
|
// A refund the provider has never heard of.
|
|
if code := postYooKassaRefundNotify(t, srv, "invented-refund", paymentID); code != http.StatusOK {
|
|
t.Fatalf("refund notify = %d, want 200", code)
|
|
}
|
|
// One that exists but is not final.
|
|
f.setRefund("pending-refund", yookassa.Refund{
|
|
Status: yookassa.StatusPending, PaymentID: paymentID,
|
|
Amount: yookassa.Amount{Value: "149.00", Currency: "RUB"},
|
|
})
|
|
if code := postYooKassaRefundNotify(t, srv, "pending-refund", paymentID); code != http.StatusOK {
|
|
t.Fatalf("refund notify = %d, want 200", code)
|
|
}
|
|
if got := readBalance(t, acc, "direct"); got != 100 {
|
|
t.Errorf("balance = %d, want 100 — an unconfirmed refund revoked chips", got)
|
|
}
|
|
if ledgerRows(t, acc, "refund") != 0 {
|
|
t.Error("an unconfirmed refund wrote a refund ledger row")
|
|
}
|
|
}
|
|
|
|
// TestYooKassaPendingRefundRecordsNothing: a refund can still be canceled while pending and the
|
|
// ledger is append-only, so the console records only a completed one. Retrying is the way out.
|
|
func TestYooKassaPendingRefundRecordsNothing(t *testing.T) {
|
|
f, shop := newFakeYooKassa(t)
|
|
srv, pay := yookassaServer(t, shop)
|
|
acc := provisionAccount(t)
|
|
orderID, _ := fundedYooKassaOrder(t, f, srv, pay, acc)
|
|
|
|
f.refundStatus = yookassa.StatusPending
|
|
base := "http://admin.test/_gm/users/" + acc.String()
|
|
_, body := consoleDo(srv.Handler(), http.MethodPost, base+"/refund", "order_id="+orderID.String(), "http://admin.test")
|
|
if !strings.Contains(body, "not completed the refund yet") || !strings.Contains(body, "nothing was recorded") {
|
|
t.Errorf("message = %q, want it to say the refund is not final and nothing was recorded", body)
|
|
}
|
|
if got := readBalance(t, acc, "direct"); got != 100 {
|
|
t.Errorf("balance = %d, want 100 — chips were revoked for an unsettled refund", got)
|
|
}
|
|
if ledgerRows(t, acc, "refund") != 0 {
|
|
t.Error("a pending refund wrote a refund ledger row")
|
|
}
|
|
|
|
// Once the provider settles it, pressing again records the reversal — the idempotency key returns
|
|
// the same refund rather than paying twice.
|
|
f.refundStatus = yookassa.StatusSucceeded
|
|
if code, body := consoleDo(srv.Handler(), http.MethodPost, base+"/refund", "order_id="+orderID.String(), "http://admin.test"); code != http.StatusOK || !strings.Contains(body, "revoked 100 chips") {
|
|
t.Fatalf("retried refund = %d, body has 'revoked 100 chips' = %v", code, strings.Contains(body, "revoked 100 chips"))
|
|
}
|
|
if got := readBalance(t, acc, "direct"); got != 0 {
|
|
t.Errorf("balance = %d, want 0", got)
|
|
}
|
|
}
|
|
|
|
// TestYooKassaConsoleRefundAfterItsOwnNotification covers the race the two recording paths really
|
|
// run: the console asks the provider to refund, the provider fires refund.succeeded at once, and the
|
|
// notification records the reversal before the console gets to. Both name the same refund id, so the
|
|
// idempotency index catches the second write and nothing is revoked twice — but the operator must be
|
|
// told the refund succeeded, not that they clicked twice.
|
|
func TestYooKassaConsoleRefundAfterItsOwnNotification(t *testing.T) {
|
|
f, shop := newFakeYooKassa(t)
|
|
srv, pay := yookassaServer(t, shop)
|
|
acc := provisionAccount(t)
|
|
orderID, paymentID := fundedYooKassaOrder(t, f, srv, pay, acc)
|
|
|
|
// The provider's notification lands first, for the refund the console is about to create.
|
|
refundID := "refund-" + paymentID
|
|
f.setRefund(refundID, yookassa.Refund{
|
|
Status: yookassa.StatusSucceeded, PaymentID: paymentID,
|
|
Amount: yookassa.Amount{Value: "149.00", Currency: "RUB"},
|
|
})
|
|
if code := postYooKassaRefundNotify(t, srv, refundID, paymentID); code != http.StatusOK {
|
|
t.Fatalf("refund notify = %d, want 200", code)
|
|
}
|
|
if got := readBalance(t, acc, "direct"); got != 0 {
|
|
t.Fatalf("balance after the notification = %d, want 0", got)
|
|
}
|
|
|
|
base := "http://admin.test/_gm/users/" + acc.String()
|
|
code, body := consoleDo(srv.Handler(), http.MethodPost, base+"/refund", "order_id="+orderID.String(), "http://admin.test")
|
|
if code != http.StatusOK {
|
|
t.Fatalf("console refund = %d, want 200", code)
|
|
}
|
|
if strings.Contains(body, "Already refunded") {
|
|
t.Errorf("the console reported a repeat click for a refund it had just made successfully: %q", body)
|
|
}
|
|
if !strings.Contains(body, "Refunded") {
|
|
t.Errorf("console message = %q, want it to report the refund succeeded", body)
|
|
}
|
|
if ledgerRows(t, acc, "refund") != 1 {
|
|
t.Errorf("refund ledger rows = %d, want 1", ledgerRows(t, acc, "refund"))
|
|
}
|
|
if abuse, loss := readRisk(t, acc); abuse || loss != 0 {
|
|
t.Errorf("risk = (abuse %v, loss %d), want none", abuse, loss)
|
|
}
|
|
}
|