//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 // 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/"): 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) } } // TestYooKassaNotifyRejectsForeignSenders checks the sender allowlist: an address outside YooKassa's // published ranges is refused before any provider call, so a forger cannot make us fan out requests. func TestYooKassaNotifyRejectsForeignSenders(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, "8.8.8.8", yookassa.EventPaymentSucceeded, paymentID, orderID.String()); code != http.StatusForbidden { t.Fatalf("notify from a foreign address = %d, want 403", code) } if got := readBalance(t, acc, "direct"); got != 0 { t.Errorf("balance = %d, want 0 — a foreign sender credited chips", 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 past its lifetime without any notification having been delivered. 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 != 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) } } // 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) } }