92ba527575
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 25s
CI / ui (pull_request) Successful in 1m17s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m50s
Replace Robokassa with YooKassa as the RUB direct-rail provider. The wallet
model is untouched: one `direct` segment, the same spend wall, the same
per-channel merchant shops (D42) and `shop` on the order (D44).
The two providers are not shaped alike, and that drives the change:
- Opening a purchase is now an outbound API call (`POST /v3/payments`,
single-stage capture, redirect confirmation). The order id is both the
`Idempotence-Key` and `metadata.order_id`, so a retried create cannot mint a
second payment and a notification always resolves to its order.
- YooKassa does NOT sign notifications, so the body is never evidence: it only
names a payment, which is re-read with `GET /v3/payments/{id}`, and only that
answer is acted on. Two guards ride on it — the payment's metadata must name
the order, and its `test` flag must match the shop's, so a test-shop payment
can never credit real chips. The sender address is checked against YooKassa's
published ranges first, which stops a forger turning each fabricated
notification into an outbound call of ours.
- A notification lost for good would leave the money taken and the chips unowed,
silently. The existing pending-order reaper now asks the provider about each
order that reached its expiry age carrying a payment id, and credits the ones
really paid — one request per order over its whole life, not polling.
- `payment.canceled` records a `failed` event, so a declined payment is finally
surfaced to the customer as PAYMENTS.md §9 already specified.
- The admin refund moves the money through `POST /v3/refunds` before recording
anything; a failed call records nothing, so the ledger cannot claim a refund
that did not happen, and the recorded id is the provider's own.
- YooKassa has no cabinet-side generic receipt: «Чеки от ЮKassa» registers one
only if the request carries it, so every payment and refund now sends an
itemized `receipt` to the D36 confirmed email. The VAT rate code is a deploy
variable; the settlement subject and method are constants.
Robokassa is retired, not deleted: the direct rail falls back to it when no
YooKassa shop is configured and no deployment sets its credentials, so reviving
it is a credentials change rather than a code change. Its variables are removed
from compose, .env.example, write-prod-env.sh and the three workflows, and
recorded in backend/internal/robokassa/README.md together with the cabinet
configuration and the revival steps. Ledger rows keep `provider = 'robokassa'`;
that literal is load-bearing for the idempotency index.
No migration and no wire change: `orders.provider_payment_id` already existed,
and the client is rail-agnostic.
Decisions D47-D51 (revising D41) and stage E12 are baked into the docs.
505 lines
21 KiB
Go
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())
|
|
}
|
|
}
|