Files
scrabble-game/backend/internal/inttest/payments_yookassa_test.go
T
Ilia Denisov 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
feat(payments): settle the direct rail through YooKassa
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.
2026-07-28 08:51:31 +02:00

505 lines
21 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
// refundCalls counts the refunds actually requested.
refundCalls int
// 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{}}
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)
_ = json.NewEncoder(w).Encode(yookassa.Refund{
ID: "refund-1", Status: yookassa.StatusSucceeded, PaymentID: body.PaymentID, Amount: body.Amount,
})
default:
w.WriteHeader(http.StatusNotFound)
}
}
// 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) {
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: yookassa.VatCodeNone,
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-1" {
t.Errorf("refund ledger row = (%s, %s), want (yookassa, refund-1)", provider, refundID)
}
}
// 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")
}
}
// TestYooKassaOrderMintsAPaymentWithAReceipt drives the purchase path over HTTP: the order endpoint
// calls the provider, threads the order id through, sends the fiscal receipt to the buyer's confirmed
// address, and hands the client the hosted payment page.
func TestYooKassaOrderMintsAPaymentWithAReceipt(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)
}
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)
}
// 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())
}
}