feat(payments): settle the direct rail through YooKassa
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.
This commit is contained in:
Ilia Denisov
2026-07-28 08:51:31 +02:00
parent 985ed40639
commit 92ba527575
37 changed files with 2638 additions and 171 deletions
+11 -3
View File
@@ -397,16 +397,24 @@ func (s *Store) Identities(ctx context.Context, accountID uuid.UUID) ([]Identity
// HasConfirmedEmail reports whether the account owns a confirmed email identity — the direct-rail
// recovery anchor a first purchase requires (D36).
func (s *Store) HasConfirmedEmail(ctx context.Context, accountID uuid.UUID) (bool, error) {
_, ok, err := s.ConfirmedEmail(ctx, accountID)
return ok, err
}
// ConfirmedEmail returns the account's confirmed email address, the oldest one first when several
// exist. Besides being the direct-rail recovery anchor (D36) it is where the rail's fiscal receipt
// is delivered, so the purchase path reads the address itself rather than only its presence.
func (s *Store) ConfirmedEmail(ctx context.Context, accountID uuid.UUID) (string, bool, error) {
ids, err := s.Identities(ctx, accountID)
if err != nil {
return false, err
return "", false, err
}
for _, id := range ids {
if id.Kind == "email" && id.Confirmed {
return true, nil
return id.ExternalID, true, nil
}
}
return false, nil
return "", false, nil
}
// ListAccounts returns accounts for the admin user list, newest first, paginated
+59 -5
View File
@@ -17,6 +17,7 @@ import (
"scrabble/backend/internal/robokassa"
"scrabble/backend/internal/robot"
"scrabble/backend/internal/telemetry"
"scrabble/backend/internal/yookassa"
)
// Config holds the backend's runtime configuration.
@@ -65,8 +66,17 @@ type Config struct {
// RendererURL is the base URL of the internal image-render sidecar (e.g.
// http://renderer:8090). Empty disables the PNG export artifact.
RendererURL string
// Robokassa configures the direct-rail (RUB) payment provider — one merchant shop per channel
// (D42). An empty set leaves the direct order and Result-callback endpoints unregistered.
// YooKassa configures the direct-rail (RUB) payment provider — one merchant shop per channel
// (D42). An empty set leaves the direct order and notification endpoints unregistered and falls
// the direct rail back to Robokassa.
YooKassa yookassa.Shops
// YooKassaVatCode is the VAT rate code stamped on every fiscal receipt line (54-ФЗ tag 1199).
// It is configurable because the rate is the one receipt attribute that genuinely changes.
YooKassaVatCode int
// Robokassa configures the retired direct-rail payment provider — one merchant shop per channel
// (D42). It is dormant: no deployment sets its credentials, so the set is empty and the rail
// resolves to YooKassa. Kept wired so restoring Robokassa is a credentials change, not a code
// change — see backend/internal/robokassa/README.md.
Robokassa robokassa.Shops
}
@@ -158,9 +168,26 @@ func Load() (Config, error) {
AdminTo: os.Getenv("BACKEND_ADMIN_EMAIL"),
}
// Robokassa direct rail: one merchant shop per channel (D42). The legacy single-shop vars seed
// the web channel so existing deploys keep working; the per-channel vars add the rest. A shop
// with no MerchantLogin is dropped (the rail stays dormant when none is configured).
// YooKassa direct rail: one merchant shop per channel (D42). A shop missing either credential is
// dropped, so the rail stays dormant until a channel is fully configured.
ykShops := yookassa.Shops{}
for channel, prefix := range map[string]string{
yookassa.ChannelWeb: "BACKEND_YOOKASSA_WEB",
yookassa.ChannelAndroid: "BACKEND_YOOKASSA_ANDROID",
} {
if shop := yookassaShop(prefix); shop.Configured() {
ykShops[channel] = shop
}
}
vatCode, err := envInt("BACKEND_YOOKASSA_VAT_CODE", yookassa.VatCodeNone)
if err != nil {
return Config{}, err
}
// Robokassa direct rail: retired but kept wired (see backend/internal/robokassa/README.md). No
// deployment sets these, so the set is empty and the direct rail resolves to YooKassa; restoring
// the credentials revives it without a code change. The legacy single-shop vars seed the web
// channel; the per-channel vars add the rest.
shops := robokassa.Shops{}
web := robokassaShop("BACKEND_ROBOKASSA_WEB")
if web.MerchantLogin == "" {
@@ -190,6 +217,8 @@ func Load() (Config, error) {
GuestRetention: guestRetention,
ExportSignKey: os.Getenv("BACKEND_EXPORT_SIGN_KEY"),
RendererURL: os.Getenv("BACKEND_RENDERER_URL"),
YooKassa: ykShops,
YooKassaVatCode: vatCode,
Robokassa: shops,
}
if err := c.validate(); err != nil {
@@ -248,6 +277,19 @@ func (c Config) validate() error {
return fmt.Errorf("config: robokassa shop %q: password1 and password2 must be set when its merchant login is", channel)
}
}
if !yookassa.ValidVatCode(c.YooKassaVatCode) {
return fmt.Errorf("config: BACKEND_YOOKASSA_VAT_CODE %d is not a 54-ФЗ VAT rate code (1..%d)", c.YooKassaVatCode, yookassa.VatCodeMax)
}
if c.YooKassa.Configured() {
// The YooKassa rail sends the customer to a hosted payment page and needs an absolute return
// URL to bring them back, which only the public base URL can supply.
if c.PublicBaseURL == "" {
return fmt.Errorf("config: BACKEND_PUBLIC_BASE_URL must be set when a YooKassa shop is configured")
}
if u, err := url.Parse(c.PublicBaseURL); err != nil || u.Scheme == "" || u.Host == "" {
return fmt.Errorf("config: BACKEND_PUBLIC_BASE_URL %q must be an absolute URL (scheme://host)", c.PublicBaseURL)
}
}
return nil
}
@@ -288,6 +330,18 @@ func envDuration(key string, fallback time.Duration) (time.Duration, error) {
return d, nil
}
// yookassaShop reads a YooKassa shop's credentials from the environment under prefix (e.g.
// "BACKEND_YOOKASSA_WEB" → _SHOP_ID / _SECRET_KEY / _TEST). _TEST marks a test shop, which lets the
// intake refuse to credit a live payment against test credentials and vice versa. A shop missing
// either credential yields a Config the caller drops.
func yookassaShop(prefix string) yookassa.Config {
return yookassa.Config{
ShopID: os.Getenv(prefix + "_SHOP_ID"),
SecretKey: os.Getenv(prefix + "_SECRET_KEY"),
IsTest: os.Getenv(prefix+"_TEST") == "1",
}
}
// robokassaShop reads a Robokassa shop's four credentials from the environment under prefix (e.g.
// "BACKEND_ROBOKASSA_WEB" → _MERCHANT_LOGIN / _PASSWORD1 / _PASSWORD2 / _TEST). A missing
// MerchantLogin yields a zero Config the caller drops.
@@ -0,0 +1,504 @@
//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())
}
}
+18 -5
View File
@@ -11,11 +11,24 @@ import (
// one manual full refund per order.
const providerAdmin = "admin"
// RefundOrderFull refunds a paid order in full at the operator's request: it revokes the funded
// chips best-effort (floored at 0, never negative — D27), records a refund ledger row, and is
// idempotent (a second call reports AlreadyRefunded). The operator performs the actual money refund
// on the rail (Robokassa cabinet / VK support / Telegram refundStarPayment); this records it.
// RefundOrderFull refunds a paid order in full at the operator's request, recording the reversal
// only. It is for the rails that have no refund API of our own to call: the operator performs the
// actual money refund there by hand (VK support, Telegram refundStarPayment, the Robokassa cabinet),
// and this records it under the operator's own idempotency key.
func (s *Service) RefundOrderFull(ctx context.Context, orderID uuid.UUID) (RefundOutcome, error) {
return s.RefundOrderFullAs(ctx, orderID, providerAdmin, orderID.String())
}
// RefundOrderFullAs refunds a paid order in full and records it under the given provider and refund
// id: it revokes the funded chips best-effort (floored at 0, never negative — D27), appends a refund
// ledger row and is idempotent on (provider, providerRefundID), so a second call reports
// AlreadyRefunded. Callers whose rail moved the money through an API pass that rail's own refund id,
// which keeps the ledger reconcilable against the provider's records; the refund id is distinct from
// the fund's payment id, so the two rows coexist under the same partial-unique index.
//
// The provider-side money movement must already have succeeded when this is called — the ledger must
// never claim a refund that did not happen.
func (s *Service) RefundOrderFullAs(ctx context.Context, orderID uuid.UUID, provider, providerRefundID string) (RefundOutcome, error) {
o, err := s.store.orderByID(ctx, orderID)
if err != nil {
return RefundOutcome{}, err
@@ -24,7 +37,7 @@ func (s *Service) RefundOrderFull(ctx context.Context, orderID uuid.UUID) (Refun
if err != nil {
return RefundOutcome{}, err
}
return s.store.refund(ctx, orderID, providerAdmin, orderID.String(), refunded, s.clock())
return s.store.refund(ctx, orderID, provider, providerRefundID, refunded, s.clock())
}
// LedgerExportRow is one append-only ledger row for the tax / reconciliation export, carrying the
@@ -64,6 +64,83 @@ func directShop(cxt Context) string {
return ""
}
// AttachProviderPayment records the provider's own payment identifier on a pending order, right
// after the provider mints it and before the customer has paid. Two later paths depend on it: the
// reconcile sweep, which asks the provider what became of an order no callback ever confirmed, and a
// refund, which must address the original payment. It does not credit anything and does not change
// the order status.
func (s *Service) AttachProviderPayment(ctx context.Context, orderID uuid.UUID, provider, providerPaymentID string) error {
return s.store.attachProviderPayment(ctx, orderID, provider, providerPaymentID, s.clock())
}
// OrderRef identifies a stored order to a provider: which rail settles it, that rail's own payment
// id, the merchant shop channel it was issued through, and the amount and account it belongs to. It
// is what the reconcile sweep and the refund path need without giving them the whole order.
type OrderRef struct {
OrderID uuid.UUID
AccountID uuid.UUID
Provider string
PaymentID string
Shop string
Amount Money
Status string
}
// orderRef projects a stored order onto an OrderRef.
func orderRef(o orderRow) (OrderRef, error) {
amount, err := MoneyFromMinor(o.expectedAmount, Currency(o.currency))
if err != nil {
return OrderRef{}, err
}
return OrderRef{
OrderID: o.orderID,
AccountID: o.accountID,
Provider: o.provider,
PaymentID: o.paymentID,
Shop: o.shop,
Amount: amount,
Status: o.status,
}, nil
}
// OrderProviderRef reads how an order reaches its provider — the rail, that rail's payment id and
// the shop it was issued through. The refund path uses it to call the right merchant account.
func (s *Service) OrderProviderRef(ctx context.Context, orderID uuid.UUID) (OrderRef, error) {
o, err := s.store.orderByID(ctx, orderID)
if err != nil {
return OrderRef{}, err
}
return orderRef(o)
}
// reconcileBatch bounds one reconcile sweep, so a backlog cannot turn a periodic tick into a long
// run of provider calls.
const reconcileBatch = 50
// PendingForReconcile returns the pending orders that have reached their expiry age while carrying a
// provider payment id — the ones where the money may well have moved but no callback ever told us.
// The caller asks the provider for each one's real outcome before ExpireOrders writes them off.
// Orders that never reached a payment are not returned: there is nothing to ask about.
func (s *Service) PendingForReconcile(ctx context.Context) ([]OrderRef, error) {
ttl, err := s.store.orderTTL(ctx)
if err != nil {
return nil, err
}
rows, err := s.store.pendingForReconcile(ctx, ttl, s.clock(), reconcileBatch)
if err != nil {
return nil, err
}
out := make([]OrderRef, 0, len(rows))
for _, r := range rows {
ref, err := orderRef(r)
if err != nil {
return nil, err
}
out = append(out, ref)
}
return out, nil
}
// OrderItem returns a pending order's human title and the amount it charges, in the order's own
// currency — the details a provider's item-lookup phase needs (VK's get_item). It reads the order
// and the pack title, honouring the pack even if it was later deactivated (mirrors Fund).
+63 -1
View File
@@ -182,6 +182,9 @@ type orderRow struct {
currency string
origin string
status string
provider string
paymentID string
shop string
}
// orderByID reads an order, or ErrOrderNotFound.
@@ -198,6 +201,12 @@ func (s *Store) orderByID(ctx context.Context, orderID uuid.UUID) (orderRow, err
if err != nil {
return orderRow{}, fmt.Errorf("payments: load order %s: %w", orderID, err)
}
return newOrderRow(o), nil
}
// newOrderRow projects a stored order onto the intake's view of it, flattening the nullable provider
// columns to their empty strings.
func newOrderRow(o model.Orders) orderRow {
return orderRow{
orderID: o.OrderID,
accountID: o.AccountID,
@@ -206,7 +215,60 @@ func (s *Store) orderByID(ctx context.Context, orderID uuid.UUID) (orderRow, err
currency: o.Currency,
origin: o.Origin,
status: o.Status,
}, nil
provider: derefString(o.Provider),
paymentID: derefString(o.ProviderPaymentID),
shop: o.Shop,
}
}
// derefString reads a nullable text column as a plain string, treating NULL as empty.
func derefString(p *string) string {
if p == nil {
return ""
}
return *p
}
// attachProviderPayment records the provider's own payment id on a pending order, as soon as the
// provider mints it. It is what later lets an unattended order be re-checked against the provider
// and a refund address the right payment; fund overwrites it with the same value when the callback
// lands. It never changes the order status.
func (s *Store) attachProviderPayment(ctx context.Context, orderID uuid.UUID, provider, providerPaymentID string, now time.Time) error {
_, err := table.Orders.
UPDATE(table.Orders.Provider, table.Orders.ProviderPaymentID, table.Orders.UpdatedAt).
SET(postgres.String(provider), postgres.String(providerPaymentID), postgres.TimestampzT(now)).
WHERE(table.Orders.OrderID.EQ(postgres.UUID(orderID))).
ExecContext(ctx, s.db)
if err != nil {
return fmt.Errorf("payments: attach provider payment to order %s: %w", orderID, err)
}
return nil
}
// pendingForReconcile reads the pending orders that have reached their expiry age and carry a
// provider payment id — the ones whose real outcome is still unknown to us because no callback ever
// arrived. The caller asks the provider what happened before the order is written off as expired.
// Orders with no provider payment id are skipped: the customer never got as far as a payment.
func (s *Store) pendingForReconcile(ctx context.Context, ttlSeconds int, now time.Time, limit int) ([]orderRow, error) {
cutoff := now.Add(-time.Duration(ttlSeconds) * time.Second)
var rows []model.Orders
err := postgres.SELECT(table.Orders.AllColumns).
FROM(table.Orders).
WHERE(table.Orders.Status.EQ(postgres.String("pending")).
AND(table.Orders.CreatedAt.LT(postgres.TimestampzT(cutoff))).
AND(table.Orders.ProviderPaymentID.IS_NOT_NULL()).
AND(table.Orders.ProviderPaymentID.NOT_EQ(postgres.String("")))).
ORDER_BY(table.Orders.CreatedAt.ASC()).
LIMIT(int64(limit)).
QueryContext(ctx, s.db, &rows)
if err != nil && !errors.Is(err, qrm.ErrNoRows) {
return nil, fmt.Errorf("payments: load orders for reconcile: %w", err)
}
out := make([]orderRow, 0, len(rows))
for _, r := range rows {
out = append(out, newOrderRow(r))
}
return out, nil
}
// FundOutcome reports the result of an intake credit: whose balance, which segment and how many
+85
View File
@@ -0,0 +1,85 @@
# Robokassa — the retired direct rail
Robokassa used to settle the **`direct`** (RUB) rail. It was replaced by
[YooKassa](../yookassa) and is now **dormant**: the code, its tests and its wiring are all still
here, but **no deployment sets its credentials**, so `Shops.Configured()` is false, the Result
callback route is never registered, and the direct rail resolves to YooKassa.
The code is kept because the merchant relationship could come back. This file records the operational
knowledge that used to live in `deploy/` and `.gitea/workflows/` and was removed from there so the
live configuration stays free of dead variables.
## How the rail is chosen
`handleWalletOrder` (`backend/internal/server/handlers_intake.go`) resolves the direct rail in this
order:
1. a configured YooKassa shop for the platform channel → YooKassa;
2. otherwise a configured Robokassa shop → **this rail**;
3. otherwise `501 rail_unavailable`.
So reviving Robokassa is a **credentials change, not a code change**: restore the environment block
below and the rail comes back on the next deploy. If both providers are configured, YooKassa wins.
## Environment variables (retired)
The backend still reads all of these. They were removed from `deploy/docker-compose.yml`,
`deploy/.env.example`, `deploy/write-prod-env.sh`, `deploy/README.md` and the three workflows.
| Backend variable (inside the container) | Meaning |
| --- | --- |
| `BACKEND_ROBOKASSA_MERCHANT_LOGIN` | Shop login. Empty ⇒ the shop is dropped. Legacy single-shop form: it seeds the **web** channel. |
| `BACKEND_ROBOKASSA_PASSWORD1` | Pass phrase #1 — signs the outgoing payment request. |
| `BACKEND_ROBOKASSA_PASSWORD2` | Pass phrase #2 — signs and so verifies the incoming Result callback. |
| `BACKEND_ROBOKASSA_TEST` | `1` adds `IsTest=1`, so the shop simulates payments against its **test** pass phrases and no money moves. Empty/`0` is live. |
| `BACKEND_ROBOKASSA_WEB_{MERCHANT_LOGIN,PASSWORD1,PASSWORD2,TEST}` | The per-channel **web** shop; overrides the legacy set above. |
| `BACKEND_ROBOKASSA_ANDROID_{MERCHANT_LOGIN,PASSWORD1,PASSWORD2,TEST}` | The per-channel **android** (RuStore) shop (D42). |
Password3 (Robokassa's JWT-invoice API) was never used.
The three-step naming this repo uses was: a Gitea secret/variable `PROD_BACKEND_ROBOKASSA_PASSWORD1`
→ mapped by the workflow to `ROBOKASSA_PASSWORD1` → mapped by compose to
`BACKEND_ROBOKASSA_PASSWORD1` inside the container.
| Contour | Gitea entries that fed the rail |
| --- | --- |
| test | secrets `TEST_BACKEND_ROBOKASSA_{MERCHANT_LOGIN,PASSWORD1,PASSWORD2}`; `ROBOKASSA_TEST` was hard-coded to `"1"` in `ci.yaml` so the contour could never take real money whatever the shop's own mode. |
| prod | secrets `PROD_BACKEND_ROBOKASSA_{MERCHANT_LOGIN,PASSWORD1,PASSWORD2}`; **variable** `PROD_BACKEND_ROBOKASSA_TEST`, deliberately a variable so go-live was a flag flip rather than a secret rotation. Mirrored in `prod-rollback.yaml` so a rollback did not dark the rail. |
**Known gap, if the rail is ever revived:** the per-channel `…_WEB_*` / `…_ANDROID_*` variables
existed in compose and in `config.go` but were **never** carried by any workflow or by
`write-prod-env.sh` — only the four legacy variables reached prod. A revival that wants the
multi-shop split must add them to the deploy plumbing too.
## Cabinet configuration
Set in the Robokassa ЛКК, per shop:
- **Result URL** — `https://<host>/pay/robokassa/result/<channel>` (`…/web`, `…/android`). The bare
`…/pay/robokassa/result` is the legacy path and is verified as the **web** shop. Method: POST.
Each shop's callback is verified only by its own Password2 (`Shops.Verifier` does not fall back).
- **Success URL** — `https://<host>/pay/robokassa/success`.
- **Fail URL** — `https://<host>/pay/robokassa/fail`.
- **Signature algorithm** — SHA-256 (the code hard-codes it; a shop set to MD5 will not verify).
- **54-ФЗ** — fiscalization was cabinet-side through Robokassa's cloud cash register (kassa + ОФД +
СНО configured by the owner), so no receipt data was ever sent from code. YooKassa does **not**
work this way: it registers a receipt only if the request carries one.
The gateway routes (`gateway/internal/connectsrv/server.go`) are still registered, so the cabinet
URLs above stay reachable; only the backend has no shop to verify against.
## Protocol notes
- The order id is a UUID and Robokassa's `InvId` is numeric, so the order rides the custom-parameter
channel as **`Shp_order`** and `InvId` is always sent as `0`. Idempotency at the credit site is
therefore keyed on the order id, not on a provider payment id.
- Outgoing signature base: `MerchantLogin:OutSum:InvId:Password1[:Shp_key=value…sorted]`.
- Incoming (Result) signature base: `OutSum:InvId:Password2[:Shp_key=value…sorted]`, compared
case-insensitively.
- The Result callback must be answered with the body `OK<InvId>`, which the gateway echoes verbatim.
## What is *not* reversible by configuration
Ledger rows written while this rail was live carry `provider = 'robokassa'`. That literal is
load-bearing for the `(provider, provider_payment_id)` idempotency index and must never be renamed or
reused for another provider.
+6
View File
@@ -3,6 +3,12 @@
// an order. It is pure provider glue — no database, no payments-domain coupling — so the payments
// domain stays provider-agnostic and this layer is unit-testable in isolation.
//
// This rail is RETIRED: YooKassa settles the direct rail now, and no deployment sets these
// credentials, so the shop set is empty and the rail is dormant. It stays wired as the fallback the
// direct rail resolves to when YooKassa has no shop, which makes reviving it a credentials change
// rather than a code change. README.md in this directory records the retired environment variables,
// the cabinet configuration and how to bring the rail back.
//
// The order is threaded through Robokassa's custom-parameter channel as Shp_order=<order id> (echoed
// back in the callback and bound into the signature), not the numeric InvId, because an order id is
// a uuid; InvId is sent as 0. Idempotency is therefore keyed on the order id at the credit site.
+7 -3
View File
@@ -79,16 +79,20 @@ func (s *Server) registerRoutes() {
s.internal.GET("/offer/pricing", s.handleOfferPricing)
}
if s.payments != nil {
// The money order endpoint dispatches by rail (direct → Robokassa, vk → VK); an
// The money order endpoint dispatches by rail (direct → YooKassa, vk → VK); an
// unsupported or unconfigured rail returns 501 from the handler. The provider callbacks are
// gateway-only (the single writer): the VK payment callback (both phases handled here), and
// the Robokassa Result callback when a merchant is configured.
// gateway-only (the single writer): the VK payment callback (both phases handled here), the
// YooKassa notification, and the retired Robokassa Result callback if a merchant is ever
// configured again.
u.POST("/wallet/order", s.handleWalletOrder)
s.internal.POST("/payments/vk/callback", s.handleVKCallback)
// The Telegram Stars rail: the bot forwards a pre_checkout validation and a completed
// payment over the reverse bot-link; the gateway proxies both onto these gateway-only routes.
s.internal.POST("/payments/telegram/precheckout", s.handleTelegramPreCheckout)
s.internal.POST("/payments/telegram/payment", s.handleTelegramPayment)
if s.yookassa.Configured() {
s.internal.POST("/payments/yookassa/notify", s.handleYooKassaNotify)
}
if s.robokassa.Configured() {
s.internal.POST("/payments/robokassa/result", s.handleRobokassaResult)
}
@@ -1,6 +1,7 @@
package server
import (
"context"
"encoding/csv"
"fmt"
"strconv"
@@ -9,11 +10,17 @@ import (
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.uber.org/zap"
"scrabble/backend/internal/payments"
)
// consoleRefund refunds a paid order in full at the operator's request. The operator performs the
// actual money refund on the rail (Robokassa cabinet / VK support / Telegram refundStarPayment);
// this records it — a refund ledger row and a floor-0 chip revoke (never negative, D27). Idempotent.
// consoleRefund refunds a paid order in full at the operator's request: it records the reversal — a
// refund ledger row and a floor-0 chip revoke (never negative, D27) — and, on the direct rail, moves
// the money back through the provider's refund API first, so one click does the whole job and the
// ledger cannot claim a refund the provider never made. The rails with no refund API of ours (VK
// support, Telegram refundStarPayment, the Robokassa cabinet) still need the operator to move the
// money by hand; this records that. Idempotent either way.
func (s *Server) consoleRefund(c *gin.Context) {
id, ok := s.consoleUUID(c, "/_gm/users")
if !ok {
@@ -29,7 +36,13 @@ func (s *Server) consoleRefund(c *gin.Context) {
s.renderConsoleMessage(c, "Refund failed", "no order to refund", back)
return
}
out, err := s.payments.RefundOrderFull(c.Request.Context(), orderID)
ctx := c.Request.Context()
ref, err := s.payments.OrderProviderRef(ctx, orderID)
if err != nil {
s.renderConsoleMessage(c, "Refund failed", err.Error(), back)
return
}
out, err := s.refundOrder(ctx, ref)
if err != nil {
s.renderConsoleMessage(c, "Refund failed", err.Error(), back)
return
@@ -46,6 +59,25 @@ func (s *Server) consoleRefund(c *gin.Context) {
s.renderConsoleMessage(c, "Refunded", msg, back)
}
// refundOrder reverses a paid order, moving the money back through the rail's own refund API when
// there is one. The provider call comes first and a failure aborts with nothing recorded: the ledger
// must never claim a refund that did not happen. The reversal is then recorded under the provider's
// own refund id, which keeps the ledger reconcilable against the provider's records.
//
// A rail with no refund API of ours records under the operator's key instead, and the operator moves
// the money by hand (VK support, Telegram refundStarPayment, the Robokassa cabinet).
func (s *Server) refundOrder(ctx context.Context, ref payments.OrderRef) (payments.RefundOutcome, error) {
if ref.Provider != providerYooKassa {
return s.payments.RefundOrderFull(ctx, ref.OrderID)
}
refundID, err := s.refundYooKassa(ctx, ref)
if err != nil {
s.log.Error("yookassa refund failed", zap.String("order", ref.OrderID.String()), zap.Error(err))
return payments.RefundOutcome{}, fmt.Errorf("the provider did not refund the payment, nothing was recorded: %w", err)
}
return s.payments.RefundOrderFullAs(ctx, ref.OrderID, providerYooKassa, refundID)
}
// consoleLedgerExport streams the entire append-only ledger as a CSV attachment for tax reporting
// and rail reconciliation. The snapshot column carries the raw purchase/refund JSON.
func (s *Server) consoleLedgerExport(c *gin.Context) {
+28 -9
View File
@@ -15,20 +15,28 @@ import (
"scrabble/backend/internal/robokassa"
)
// Ledger/order provider tags per rail.
// Ledger/order provider tags per rail. providerRobokassa is retired but never renamed or reused:
// historical ledger rows carry it, and it is load-bearing for the (provider, provider_payment_id)
// idempotency index.
const (
providerRobokassa = "robokassa" // the direct (RUB) rail
providerYooKassa = "yookassa" // the direct (RUB) rail
providerRobokassa = "robokassa" // the retired direct (RUB) rail
providerVK = "vk" // the VK Votes rail
providerTelegram = "telegram" // the Telegram Stars (XTR) rail
)
// yookassaReturnPath is where YooKassa returns the customer's browser after the hosted payment page.
// The credit never rides this redirect — it rides the server notification — so the page only closes
// the payment window and drops the customer back into the live app.
const yookassaReturnPath = "/pay/yookassa/return"
// walletOrderRequest is the POST body of a chip-pack purchase: the pack to fund.
type walletOrderRequest struct {
ProductID string `json:"product_id"`
}
// walletOrderResponse returns the created order id and the rail's launch details. RedirectURL is
// the provider's hosted-payment URL for the direct rail (empty for VK/Telegram, which settle
// the provider's hosted-payment page for the direct rail (empty for VK/Telegram, which settle
// in-app). Rail names the settling rail so the gateway knows how to launch it; for the Telegram
// Stars rail the gateway mints the invoice link from InvoiceTitle and InvoiceAmount (whole stars)
// via the bot and returns it in RedirectURL.
@@ -41,7 +49,7 @@ type walletOrderResponse struct {
}
// handleWalletOrder opens a pending order to fund a chip pack and returns the rail's launch
// details for the client: the Robokassa hosted-payment URL (direct), the order id for
// details for the client: the provider's hosted-payment URL (direct), the order id for
// VKWebAppShowOrderBox (VK), or the pack title and star amount the gateway mints into a Stars
// invoice link (Telegram). It enforces the wallet gate and, on the direct rail, D36 (a purchase
// requires a confirmed email anchor). No chips are credited here — only later, by the verified
@@ -84,13 +92,20 @@ func (s *Server) handleWalletOrder(c *gin.Context) {
}
switch cxt.Kind {
case payments.SourceDirect:
shop, ok := s.robokassa.Shop(cxt.Subtype)
if !ok {
// YooKassa settles the direct rail. Robokassa is retired and normally unconfigured, so it is
// only reached when YooKassa has no shop for this channel — which is what makes reviving the
// old rail a credentials change rather than a code change. The rail is resolved before any
// account-level gate, so an unconfigured rail still reads as unavailable rather than as
// something the customer could fix.
ykShop, onYooKassa := s.yookassa.Shop(cxt.Subtype)
rkShop, onRobokassa := s.robokassa.Shop(cxt.Subtype)
if !onYooKassa && !onRobokassa {
c.AbortWithStatusJSON(http.StatusNotImplemented, errorResponse{Error: errorBody{Code: "rail_unavailable", Message: "this payment method is not available"}})
return
}
// D36: a direct purchase requires a confirmed email anchor.
hasEmail, err := s.accounts.HasConfirmedEmail(ctx, uid)
// D36: a direct purchase requires a confirmed email anchor, which is also where the fiscal
// receipt is delivered.
email, hasEmail, err := s.accounts.ConfirmedEmail(ctx, uid)
if err != nil {
s.abortErr(c, err)
return
@@ -99,6 +114,10 @@ func (s *Server) handleWalletOrder(c *gin.Context) {
c.AbortWithStatusJSON(http.StatusForbidden, errorResponse{Error: errorBody{Code: "email_required", Message: "confirm your email before making a purchase"}})
return
}
if onYooKassa {
s.orderYooKassa(c, uid, cxt, present, productID, ykShop, email)
return
}
res, err := s.payments.CreateOrder(ctx, uid, cxt, present, productID, providerRobokassa)
if err != nil {
s.abortErr(c, err)
@@ -106,7 +125,7 @@ func (s *Server) handleWalletOrder(c *gin.Context) {
}
c.JSON(http.StatusOK, walletOrderResponse{
OrderID: res.OrderID.String(),
RedirectURL: shop.PaymentURL(res.OrderID, res.Amount.Major(), res.Title),
RedirectURL: rkShop.PaymentURL(res.OrderID, res.Amount.Major(), res.Title),
Rail: providerRobokassa,
})
case payments.SourceVK:
@@ -0,0 +1,355 @@
package server
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.uber.org/zap"
"scrabble/backend/internal/payments"
"scrabble/backend/internal/yookassa"
)
// maxNotifyBytes caps the notification body the backend will read. The gateway already forwards only
// what it read from the provider; this is the backend's own ceiling on an untrusted body.
const maxNotifyBytes = 1 << 20
// orderYooKassa opens a pending order on the direct rail and mints the YooKassa payment the customer
// is redirected to. shop is the merchant shop the caller resolved for this platform channel, and
// email is the account's confirmed address (D36), which doubles as the delivery address of the fiscal
// receipt this rail must send with every payment. No chips are credited here —
// only later, by the verified notification (or the reconcile sweep).
func (s *Server) orderYooKassa(c *gin.Context, uid uuid.UUID, cxt payments.Context, present []payments.Source, productID uuid.UUID, shop yookassa.Config, email string) {
ctx := c.Request.Context()
res, err := s.payments.CreateOrder(ctx, uid, cxt, present, productID, providerYooKassa)
if err != nil {
s.abortErr(c, err)
return
}
amount := yookassa.Amount{Value: res.Amount.Major(), Currency: string(res.Amount.Currency())}
payment, err := shop.CreatePayment(ctx, yookassa.PaymentRequest{
Amount: amount,
Capture: true,
Confirmation: yookassa.Confirmation{
Type: yookassa.ConfirmationRedirect,
ReturnURL: strings.TrimSuffix(s.publicURL, "/") + yookassaReturnPath,
},
Description: yookassa.TruncateDescription(res.Title),
Metadata: map[string]string{yookassa.MetadataOrderID: res.OrderID.String()},
// «Чеки от ЮKassa»: the receipt is registered only if we send it, so a malformed one is an
// API error here rather than a silently missing fiscal document.
Receipt: yookassa.SingleItemReceipt(email, res.Title, amount, s.vatCode),
}, res.OrderID.String())
if err != nil {
// The order stays pending and unpaid; it expires on its own. The customer sees the generic
// error, and the log carries the provider's own description (which names the offending
// parameter when a receipt is at fault).
s.log.Error("yookassa create payment failed", zap.String("order", res.OrderID.String()), zap.Error(err))
c.AbortWithStatusJSON(http.StatusBadGateway, errorResponse{Error: errorBody{Code: "rail_unavailable", Message: "this payment method is not available right now"}})
return
}
if payment.Confirmation.ConfirmationURL == "" {
s.log.Error("yookassa payment has no confirmation url",
zap.String("order", res.OrderID.String()), zap.String("payment", payment.ID))
c.AbortWithStatusJSON(http.StatusBadGateway, errorResponse{Error: errorBody{Code: "rail_unavailable", Message: "this payment method is not available right now"}})
return
}
// Record the provider's payment id so the reconcile sweep and any refund can address it. A
// failure here is logged and tolerated: the credit path matches on the order id carried in the
// payment metadata, so the purchase still completes — only the safety net and refunds lose their
// handle on this one order.
if err := s.payments.AttachProviderPayment(ctx, res.OrderID, providerYooKassa, payment.ID); err != nil {
s.log.Error("yookassa attach payment id failed",
zap.String("order", res.OrderID.String()), zap.String("payment", payment.ID), zap.Error(err))
}
c.JSON(http.StatusOK, walletOrderResponse{
OrderID: res.OrderID.String(),
RedirectURL: payment.Confirmation.ConfirmationURL,
Rail: providerYooKassa,
})
}
// handleYooKassaNotify is the YooKassa webhook, reached only through the gateway (which checks the
// sender address and forwards the raw JSON body on the internal, gateway-only route).
//
// YooKassa does not sign notifications, so the body is never evidence: it only names a payment to
// re-read. Everything acted on comes from the confirming GetPayment. A succeeded payment credits its
// order exactly once (idempotent, and honoured even if the order expired); a canceled one records a
// failed event so the customer is told the attempt did not go through.
//
// The reply tells the provider whether to redeliver: 200 for anything durably decided — including a
// duplicate and a permanent rejection, which no retry could fix — and 5xx only for a transient
// failure we want redelivered (YooKassa retries for 24 hours). An unrecognised sender is a 403,
// which is also redelivered: if YooKassa ever changed its published ranges, the retries plus the
// reconcile sweep would keep the money owed while the allowlist is corrected.
func (s *Server) handleYooKassaNotify(c *gin.Context) {
// The sender check runs before anything else. It is defence in depth — the confirming GetPayment
// is what actually establishes authenticity — but it is what stops a forger from turning each
// fabricated notification into an outbound API call of ours. The address is the true sender,
// forwarded by the gateway, which is the only hop that sees it.
if ip := clientIP(c); !yookassa.AllowedSender(ip) {
s.log.Warn("yookassa notify: sender is not a YooKassa address", zap.String("ip", ip))
c.String(http.StatusForbidden, "forbidden")
return
}
raw, err := io.ReadAll(io.LimitReader(c.Request.Body, maxNotifyBytes))
if err != nil {
c.String(http.StatusInternalServerError, "error")
return
}
note, err := yookassa.ParseNotification(raw)
if err != nil {
s.log.Warn("yookassa notify: malformed body", zap.Error(err))
c.String(http.StatusOK, "ignored")
return
}
switch note.Event {
case yookassa.EventPaymentSucceeded, yookassa.EventPaymentCanceled:
default:
// Refund events are informational: refunds are issued through the API, which records them
// synchronously. Anything else is a subscription we do not act on.
c.String(http.StatusOK, "ignored")
return
}
claimed, err := note.Payment()
if err != nil {
s.log.Warn("yookassa notify: unusable payment object", zap.String("event", note.Event), zap.Error(err))
c.String(http.StatusOK, "ignored")
return
}
orderID, err := uuid.Parse(claimed.OrderID())
if err != nil {
s.log.Warn("yookassa notify: no order in payment metadata", zap.String("payment", claimed.ID))
c.String(http.StatusOK, "ignored")
return
}
ctx := c.Request.Context()
ref, err := s.payments.OrderProviderRef(ctx, orderID)
if errors.Is(err, payments.ErrOrderNotFound) {
s.log.Warn("yookassa notify: unknown order", zap.String("order", orderID.String()), zap.String("payment", claimed.ID))
c.String(http.StatusOK, "ignored")
return
}
if err != nil {
s.log.Error("yookassa notify: order lookup failed", zap.String("order", orderID.String()), zap.Error(err))
c.String(http.StatusInternalServerError, "error")
return
}
payment, shop, err := s.confirmYooKassaPayment(ctx, claimed.ID, claimed.Recipient.AccountID, ref)
if err != nil {
var apiErr *yookassa.APIError
if errors.As(err, &apiErr) && !apiErr.Retryable() {
// The provider says this payment is not ours or not readable — a forged or stale
// notification. Nothing to redeliver.
s.log.Warn("yookassa notify: payment not confirmed",
zap.String("order", orderID.String()), zap.String("payment", claimed.ID), zap.Error(err))
c.String(http.StatusOK, "ignored")
return
}
s.log.Error("yookassa notify: confirm failed",
zap.String("order", orderID.String()), zap.String("payment", claimed.ID), zap.Error(err))
c.String(http.StatusInternalServerError, "error")
return
}
if !s.yooKassaPaymentMatchesOrder(payment, shop, orderID) {
c.String(http.StatusOK, "ignored")
return
}
switch payment.Status {
case yookassa.StatusSucceeded:
if err := s.creditYooKassaPayment(ctx, orderID, payment); err != nil {
if isPermanentFundError(err) {
s.log.Warn("yookassa notify rejected", zap.String("order", orderID.String()), zap.Error(err))
c.String(http.StatusOK, "rejected")
return
}
s.log.Error("yookassa fund failed", zap.String("order", orderID.String()), zap.Error(err))
c.String(http.StatusInternalServerError, "error")
return
}
case yookassa.StatusCanceled:
// An active decline (§9): the customer is told the attempt failed. An order abandoned without
// a decline never reaches here — it just expires, invisibly.
s.recordYooKassaFailure(ctx, ref.AccountID, orderID, payment)
default:
// Still pending: the notification raced ahead of the payment, or was forged. Either way the
// reconcile sweep will settle the order, so there is nothing to redeliver.
s.log.Info("yookassa notify: payment not final",
zap.String("order", orderID.String()), zap.String("status", payment.Status))
}
c.String(http.StatusOK, "ok")
}
// confirmYooKassaPayment re-reads a payment from the API — the authenticity check for the whole rail,
// since notifications are unsigned. The shop is chosen by the shop id the payment claims to have been
// paid to, falling back to the merchant channel recorded on the order; a claim naming a shop we do
// not run is not honoured with our own credentials.
func (s *Server) confirmYooKassaPayment(ctx context.Context, paymentID, claimedShopID string, ref payments.OrderRef) (yookassa.Payment, yookassa.Config, error) {
_, shop, ok := s.yookassa.ByShopID(claimedShopID)
if !ok {
shop, ok = s.yookassa.Shop(ref.Shop)
}
if !ok {
return yookassa.Payment{}, yookassa.Config{}, yookassa.ErrUnconfigured
}
payment, err := shop.GetPayment(ctx, paymentID)
return payment, shop, err
}
// yooKassaPaymentMatchesOrder checks the confirmed payment really belongs to the order being
// credited and to the kind of shop we think it is. It rejects a payment whose metadata names a
// different order, and — the guard that keeps play money out of the live ledger — a live payment
// arriving on test credentials or a test payment on live ones.
func (s *Server) yooKassaPaymentMatchesOrder(payment yookassa.Payment, shop yookassa.Config, orderID uuid.UUID) bool {
if payment.OrderID() != orderID.String() {
s.log.Warn("yookassa: confirmed payment belongs to another order",
zap.String("order", orderID.String()), zap.String("payment", payment.ID),
zap.String("payment_order", payment.OrderID()))
return false
}
if payment.Test != shop.IsTest {
s.log.Error("yookassa: payment test flag does not match the shop; refusing to credit",
zap.String("order", orderID.String()), zap.String("payment", payment.ID),
zap.Bool("payment_test", payment.Test), zap.Bool("shop_test", shop.IsTest))
return false
}
return true
}
// creditYooKassaPayment credits a confirmed succeeded payment to its order exactly once and records
// the succeeded event a duplicate must not re-emit.
func (s *Server) creditYooKassaPayment(ctx context.Context, orderID uuid.UUID, payment yookassa.Payment) error {
paid, err := payments.ParseMoney(payment.Amount.Value, payments.Currency(payment.Amount.Currency))
if err != nil {
return err
}
outcome, err := s.payments.Fund(ctx, orderID, providerYooKassa, payment.ID, paid)
if err != nil {
return err
}
if outcome.AlreadyCredited {
return nil
}
payload, _ := json.Marshal(map[string]any{"chips": outcome.Chips, "source": string(outcome.Source)})
if err := s.payments.RecordPaymentEvent(ctx, outcome.AccountID, &orderID, "succeeded", payload); err != nil {
// The credit is already committed; only the in-app notification is lost, and the wallet still
// refreshes on the client's return.
s.log.Error("record payment event failed", zap.String("order", orderID.String()), zap.Error(err))
}
return nil
}
// recordYooKassaFailure records the failed payment event for an actively declined payment, so the
// customer learns the attempt did not go through instead of watching a balance that never moves.
func (s *Server) recordYooKassaFailure(ctx context.Context, accountID uuid.UUID, orderID uuid.UUID, payment yookassa.Payment) {
reason := ""
if payment.CancellationDetails != nil {
reason = payment.CancellationDetails.Reason
}
s.log.Info("yookassa payment canceled",
zap.String("order", orderID.String()), zap.String("payment", payment.ID), zap.String("reason", reason))
payload, _ := json.Marshal(map[string]any{"reason": reason})
if err := s.payments.RecordPaymentEvent(ctx, accountID, &orderID, "failed", payload); err != nil {
s.log.Error("record payment event failed", zap.String("order", orderID.String()), zap.Error(err))
}
}
// isPermanentFundError reports whether a credit failed for a reason no retry can fix — an order that
// does not exist, an amount that does not match, or a product that is no longer a chip pack.
func isPermanentFundError(err error) bool {
return errors.Is(err, payments.ErrOrderNotFound) || errors.Is(err, payments.ErrAmountMismatch) ||
errors.Is(err, payments.ErrNotAPack) || errors.Is(err, payments.ErrProductNotFound)
}
// refundYooKassa returns the money for a paid order through the YooKassa refund API and reports the
// provider's own refund id, which the ledger then records. The refund carries a receipt because the
// rail registers a refund receipt the same way it registers a payment one, and the order id as the
// idempotency key, so a repeated attempt returns the original refund instead of paying twice.
//
// It is called before anything is recorded: if the money does not move, nothing is written, and the
// ledger never claims a refund that did not happen.
func (s *Server) refundYooKassa(ctx context.Context, ref payments.OrderRef) (string, error) {
shop, ok := s.yookassa.Shop(ref.Shop)
if !ok {
return "", yookassa.ErrUnconfigured
}
if ref.PaymentID == "" {
return "", errors.New("the order carries no provider payment id to refund")
}
title, _, err := s.payments.OrderItem(ctx, ref.OrderID)
if err != nil {
return "", err
}
email, _, err := s.accounts.ConfirmedEmail(ctx, ref.AccountID)
if err != nil {
return "", err
}
amount := yookassa.Amount{Value: ref.Amount.Major(), Currency: string(ref.Amount.Currency())}
refund, err := shop.CreateRefund(ctx, yookassa.RefundRequest{
PaymentID: ref.PaymentID,
Amount: amount,
Receipt: yookassa.SingleItemReceipt(email, title, amount, s.vatCode),
}, ref.OrderID.String())
if err != nil {
return "", err
}
if refund.ID == "" {
return "", errors.New("the provider returned a refund with no id")
}
return refund.ID, nil
}
// ReconcileYooKassaOrders asks YooKassa what became of every pending order that reached its expiry
// age while carrying a payment id, and credits the ones that were in fact paid. It is the safety net
// for a notification that was lost for good: YooKassa redelivers for 24 hours, so a short outage
// heals itself, but a notification that never arrived at all would otherwise leave the money taken
// and the chips unowed. It runs once per order, just before the order is written off as expired.
//
// It returns how many orders it credited. Failures are logged and skipped: the next sweep retries.
func (s *Server) ReconcileYooKassaOrders(ctx context.Context) int {
if !s.yookassa.Configured() || s.payments == nil {
return 0
}
refs, err := s.payments.PendingForReconcile(ctx)
if err != nil {
s.log.Warn("yookassa reconcile: read pending orders failed", zap.Error(err))
return 0
}
credited := 0
for _, ref := range refs {
if ref.Provider != providerYooKassa || ref.PaymentID == "" {
continue
}
shop, ok := s.yookassa.Shop(ref.Shop)
if !ok {
continue
}
payment, err := shop.GetPayment(ctx, ref.PaymentID)
if err != nil {
s.log.Warn("yookassa reconcile: payment lookup failed",
zap.String("order", ref.OrderID.String()), zap.String("payment", ref.PaymentID), zap.Error(err))
continue
}
if payment.Status != yookassa.StatusSucceeded || !s.yooKassaPaymentMatchesOrder(payment, shop, ref.OrderID) {
continue
}
if err := s.creditYooKassaPayment(ctx, ref.OrderID, payment); err != nil {
s.log.Error("yookassa reconcile: credit failed",
zap.String("order", ref.OrderID.String()), zap.Error(err))
continue
}
s.log.Info("yookassa reconcile: credited an order no notification confirmed",
zap.String("order", ref.OrderID.String()), zap.String("payment", payment.ID))
credited++
}
return credited
}
+19 -2
View File
@@ -36,6 +36,7 @@ import (
"scrabble/backend/internal/session"
"scrabble/backend/internal/social"
"scrabble/backend/internal/telemetry"
"scrabble/backend/internal/yookassa"
)
// shutdownTimeout bounds how long Run waits for in-flight requests to finish
@@ -115,8 +116,18 @@ type Deps struct {
// Renderer is the image-render sidecar client for the PNG export artifact. A
// nil Renderer makes the PNG download answer 404 (the GCG artifact still works).
Renderer *render.Client
// Robokassa configures the direct-rail (RUB) provider — one merchant shop per channel; an empty
// set leaves the order and Result-callback endpoints unregistered.
// YooKassa configures the direct-rail (RUB) provider — one merchant shop per channel; an empty
// set leaves the order and notification endpoints unregistered and falls the direct rail back to
// Robokassa.
YooKassa yookassa.Shops
// YooKassaVatCode is the VAT rate code stamped on every fiscal receipt line (54-ФЗ tag 1199).
YooKassaVatCode int
// PublicBaseURL is the canonical https origin the payment return URL is built from. It is
// required whenever YooKassa is configured.
PublicBaseURL string
// Robokassa configures the retired direct-rail provider — one merchant shop per channel. No
// deployment sets it, so the set is empty and the direct rail resolves to YooKassa; it stays
// wired so restoring the rail is a credentials change (backend/internal/robokassa/README.md).
Robokassa robokassa.Shops
}
@@ -146,6 +157,9 @@ type Server struct {
ads *ads.Service
payments *payments.Service
gamelimits *gamelimits.Service
yookassa yookassa.Shops
vatCode int
publicURL string
robokassa robokassa.Shops
notifier notify.Publisher
console *adminconsole.Renderer
@@ -201,6 +215,9 @@ func New(addr string, deps Deps) *Server {
ads: deps.Ads,
payments: deps.Payments,
gamelimits: deps.GameLimits,
yookassa: deps.YooKassa,
vatCode: deps.YooKassaVatCode,
publicURL: deps.PublicBaseURL,
robokassa: deps.Robokassa,
notifier: notifier,
renderer: deps.Renderer,
+98
View File
@@ -0,0 +1,98 @@
package yookassa
import (
"encoding/json"
"fmt"
"net/netip"
)
// Notification events this integration subscribes to. An event name is "<object>.<status>": the
// object whose status changed and the status it entered.
const (
// EventPaymentSucceeded means the money was taken and the order may be credited.
EventPaymentSucceeded = "payment.succeeded"
// EventPaymentCanceled means the payment was actively declined or abandoned; nothing is credited
// and the payer is told the attempt failed.
EventPaymentCanceled = "payment.canceled"
// EventRefundSucceeded reports a completed refund. Refunds here are always initiated by an
// operator through the API, which records them synchronously, so this event is informational.
EventRefundSucceeded = "refund.succeeded"
)
// notificationType is the fixed value of a notification envelope's type field.
const notificationType = "notification"
// Notification is an incoming webhook envelope. Object is left raw because its shape depends on the
// event — a payment for payment.*, a refund for refund.* — and because nothing in it may be acted on
// before GetPayment confirms it: YooKassa does not sign notifications.
type Notification struct {
Type string `json:"type"`
Event string `json:"event"`
Object json.RawMessage `json:"object"`
}
// ParseNotification decodes a webhook body and checks the envelope is a notification with an event.
// It deliberately validates nothing else: the body is untrusted input whose only job is to name an
// object to re-read from the API.
func ParseNotification(body []byte) (Notification, error) {
var n Notification
if err := json.Unmarshal(body, &n); err != nil {
return Notification{}, fmt.Errorf("yookassa: decode notification: %w", err)
}
if n.Type != notificationType || n.Event == "" {
return Notification{}, fmt.Errorf("yookassa: not a notification envelope (type %q, event %q)", n.Type, n.Event)
}
return n, nil
}
// Payment decodes the notification's object as a payment. Use it only for payment.* events; it
// yields the payment id to re-read, never the payment state to act on.
func (n Notification) Payment() (Payment, error) {
var p Payment
if err := json.Unmarshal(n.Object, &p); err != nil {
return Payment{}, fmt.Errorf("yookassa: decode notification payment: %w", err)
}
if p.ID == "" {
return Payment{}, fmt.Errorf("yookassa: notification payment has no id")
}
return p, nil
}
// senderPrefixes are the address ranges YooKassa delivers notifications from
// (https://yookassa.ru/developers/using-api/webhooks). Single addresses are expressed as /32 and
// /128 prefixes. The list is defence in depth only — the confirming GetPayment is what actually
// establishes authenticity — so it is kept deliberately literal and easy to audit against the docs.
var senderPrefixes = []netip.Prefix{
netip.MustParsePrefix("185.71.76.0/27"),
netip.MustParsePrefix("185.71.77.0/27"),
netip.MustParsePrefix("77.75.153.0/25"),
netip.MustParsePrefix("77.75.156.11/32"),
netip.MustParsePrefix("77.75.156.35/32"),
netip.MustParsePrefix("77.75.154.128/25"),
netip.MustParsePrefix("2a02:5180::/32"),
}
// AllowedIP reports whether addr is one of YooKassa's notification senders. An IPv4-mapped IPv6
// address is unmapped first, so a dual-stack listener's view of an IPv4 sender still matches.
func AllowedIP(addr netip.Addr) bool {
addr = addr.Unmap()
if !addr.IsValid() {
return false
}
for _, p := range senderPrefixes {
if p.Contains(addr) {
return true
}
}
return false
}
// AllowedSender reports whether the textual address ip is one of YooKassa's notification senders. An
// unparseable address is not allowed.
func AllowedSender(ip string) bool {
addr, err := netip.ParseAddr(ip)
if err != nil {
return false
}
return AllowedIP(addr)
}
+90
View File
@@ -0,0 +1,90 @@
package yookassa
import (
"testing"
)
func TestParseNotification(t *testing.T) {
n, err := ParseNotification([]byte(`{"type":"notification","event":"payment.succeeded",
"object":{"id":"pay-1","status":"succeeded","paid":true,"metadata":{"order_id":"0197-order"}}}`))
if err != nil {
t.Fatalf("parse notification: %v", err)
}
if n.Event != EventPaymentSucceeded {
t.Errorf("event = %q, want %q", n.Event, EventPaymentSucceeded)
}
p, err := n.Payment()
if err != nil {
t.Fatalf("decode notification payment: %v", err)
}
if p.ID != "pay-1" {
t.Errorf("payment id = %q, want pay-1", p.ID)
}
if p.OrderID() != "0197-order" {
t.Errorf("order id = %q, want the order carried in metadata", p.OrderID())
}
}
func TestParseNotificationRejectsNonEnvelopes(t *testing.T) {
for name, body := range map[string]string{
"not json": `nonsense`,
"wrong type": `{"type":"payment","event":"payment.succeeded","object":{"id":"pay-1"}}`,
"no event": `{"type":"notification","object":{"id":"pay-1"}}`,
"empty object": `{}`,
} {
if _, err := ParseNotification([]byte(body)); err == nil {
t.Errorf("%s: accepted as a notification envelope", name)
}
}
}
func TestNotificationPaymentNeedsAnID(t *testing.T) {
// Without an id there is nothing to re-read from the API, and the body itself is never evidence.
n, err := ParseNotification([]byte(`{"type":"notification","event":"payment.succeeded","object":{"status":"succeeded"}}`))
if err != nil {
t.Fatalf("parse notification: %v", err)
}
if _, err := n.Payment(); err == nil {
t.Error("a payment object with no id was accepted")
}
}
func TestAllowedSender(t *testing.T) {
allowed := []string{
"185.71.76.0", "185.71.76.31", // /27 bounds
"185.71.77.15",
"77.75.153.0", "77.75.153.127", // /25 bounds
"77.75.156.11", "77.75.156.35", // the two single addresses
"77.75.154.128", "77.75.154.255",
"2a02:5180::1", "2a02:5180:ffff::abcd",
}
for _, ip := range allowed {
if !AllowedSender(ip) {
t.Errorf("%s rejected, want allowed", ip)
}
}
denied := []string{
"185.71.76.32", "185.71.75.255", // just outside the /27
"77.75.153.128", // just outside the /25
"77.75.156.12", // adjacent to a single allowed address
"77.75.154.127",
"2a02:5181::1",
"8.8.8.8", "127.0.0.1", "",
"not an address",
}
for _, ip := range denied {
if AllowedSender(ip) {
t.Errorf("%s allowed, want rejected", ip)
}
}
}
func TestAllowedSenderUnmapsIPv4(t *testing.T) {
// A dual-stack listener reports an IPv4 peer as ::ffff:a.b.c.d; it must still match the v4 range.
if !AllowedSender("::ffff:77.75.156.11") {
t.Error("an IPv4-mapped allowed sender was rejected")
}
if AllowedSender("::ffff:8.8.8.8") {
t.Error("an IPv4-mapped foreign sender was allowed")
}
}
+82
View File
@@ -0,0 +1,82 @@
package yookassa
// Fiscal receipt attributes for «Чеки от ЮKassa» (54-ФЗ). YooKassa registers the receipt itself —
// no cash register to rent, no OFD contract — but only if the payment and refund requests carry a
// receipt object, so unlike the previous rail's cabinet-side receipts these fields are load-bearing
// code. Reference:
// https://yookassa.ru/developers/payment-acceptance/receipts/54fz/yoomoney/parameters-values
const (
// PaymentSubjectService is the settlement subject (54-ФЗ tag 1212) — what the money is taken
// for. Chips are sold as a service ("Услуга").
PaymentSubjectService = "service"
// PaymentModeFullPayment is the settlement method (54-ФЗ tag 1214) — the payer pays and receives
// the goods at once ("Полный расчет"). «Чеки от ЮKassa» accept only this and full_prepayment;
// partial prepayment, advance and credit are not supported.
PaymentModeFullPayment = "full_payment"
// VatCodeNone is the VAT rate code (54-ФЗ tag 1199) for «Без НДС» — the rate a sole proprietor on
// УСН or ПСН charges. It is the default; the deployed value is configurable because the rate is
// the one receipt attribute that genuinely changes (Russia raised the main rate on 1 Jan 2026,
// adding codes 11 and 12).
VatCodeNone = 1
// VatCodeMax is the highest VAT rate code the API accepts; codes run 1..12.
VatCodeMax = 12
)
// Customer is the payer's contact data for delivering the receipt. «Чеки от ЮKassa» deliver only by
// email (no SMS), so Email is required — the direct rail's confirmed email anchor supplies it.
type Customer struct {
Email string `json:"email,omitempty"`
}
// ReceiptItem is one line of a fiscal receipt: what was sold, how much of it, for how much, and the
// three fiscal attributes that classify it.
type ReceiptItem struct {
Description string `json:"description"`
Quantity float64 `json:"quantity"`
Amount Amount `json:"amount"`
VatCode int `json:"vat_code"`
PaymentSubject string `json:"payment_subject"`
PaymentMode string `json:"payment_mode"`
}
// Receipt is the fiscal receipt data sent alongside a payment or a refund. tax_system_code is
// deliberately absent: YooKassa ignores it for «Чеки от ЮKassa».
type Receipt struct {
Customer Customer `json:"customer"`
Items []ReceiptItem `json:"items"`
}
// maxDescriptionRunes is the API's limit for both a payment description and a receipt line
// description (54-ФЗ tag 1030). A longer title is truncated rather than rejected, so an over-long
// product name cannot block a purchase.
const maxDescriptionRunes = 128
// SingleItemReceipt builds a one-line receipt for selling title at amount to email. Chip packs are
// always a single line bought once, so quantity is 1 and the line amount is the whole payment.
func SingleItemReceipt(email, title string, amount Amount, vatCode int) *Receipt {
return &Receipt{
Customer: Customer{Email: email},
Items: []ReceiptItem{{
Description: TruncateDescription(title),
Quantity: 1,
Amount: amount,
VatCode: vatCode,
PaymentSubject: PaymentSubjectService,
PaymentMode: PaymentModeFullPayment,
}},
}
}
// TruncateDescription shortens s to the API's description limit, counting runes rather than bytes so
// a Cyrillic title is not cut mid-character.
func TruncateDescription(s string) string {
r := []rune(s)
if len(r) <= maxDescriptionRunes {
return s
}
return string(r[:maxDescriptionRunes])
}
// ValidVatCode reports whether code is within the range the API accepts (1..12).
func ValidVatCode(code int) bool { return code >= 1 && code <= VatCodeMax }
+58
View File
@@ -0,0 +1,58 @@
package yookassa
// Shops is a set of YooKassa merchant shops keyed by channel — the subtype of the trusted X-Platform
// signal for the direct rail ("web", "android"; "ios" later). The direct rail routes a payment to the
// per-channel shop for separate merchant accounts, accounting and receipts, while every shop still
// credits the one direct wallet (docs/PAYMENTS_DECISIONS_ru.md D42). An empty set leaves the direct
// order and notification endpoints unregistered.
type Shops map[string]Config
// Channel constants name the direct-rail X-Platform subtypes a shop is keyed by. ChannelWeb is the
// default: an unknown or empty channel routes here on the order side.
const (
ChannelWeb = "web"
ChannelAndroid = "android"
)
// Shop returns the shop that issues an order for channel, falling back to the web shop when channel
// is unknown, empty or not configured. The fallback is safe: routing only chooses the merchant
// account and receipt, never the credited wallet (always direct, D42), so a mis-attributed channel
// costs at most accounting accuracy, not money. The second result is false when neither the channel
// nor the web shop is configured.
func (s Shops) Shop(channel string) (Config, bool) {
if channel != "" {
if c, ok := s[channel]; ok && c.Configured() {
return c, true
}
}
if c, ok := s[ChannelWeb]; ok && c.Configured() {
return c, true
}
return Config{}, false
}
// ByShopID returns the shop with the given YooKassa shop id, which is how an incoming notification is
// attributed to one of several configured shops (the payment object reports it as
// recipient.account_id). Unlike Shop it does not fall back: an unrecognised shop id means the
// notification was not meant for us, and the caller must not guess at credentials.
func (s Shops) ByShopID(shopID string) (channel string, cfg Config, ok bool) {
if shopID == "" {
return "", Config{}, false
}
for ch, c := range s {
if c.Configured() && c.ShopID == shopID {
return ch, c, true
}
}
return "", Config{}, false
}
// Configured reports whether at least one shop has credentials (the YooKassa direct rail is live).
func (s Shops) Configured() bool {
for _, c := range s {
if c.Configured() {
return true
}
}
return false
}
+112
View File
@@ -0,0 +1,112 @@
package yookassa
import (
"strings"
"testing"
)
func testShops() Shops {
return Shops{
ChannelWeb: {ShopID: "100500", SecretKey: "web-secret"},
ChannelAndroid: {ShopID: "100700", SecretKey: "android-secret"},
}
}
func TestShopsShopRouting(t *testing.T) {
s := testShops()
for channel, wantShopID := range map[string]string{
ChannelWeb: "100500",
ChannelAndroid: "100700",
"ios": "100500", // an unconfigured channel falls back to web
"": "100500", // so does an unknown platform subtype
} {
got, ok := s.Shop(channel)
if !ok || got.ShopID != wantShopID {
t.Errorf("Shop(%q) = %q ok=%v, want %q", channel, got.ShopID, ok, wantShopID)
}
}
}
func TestShopsShopWithoutWebFallback(t *testing.T) {
// Only android configured: an unknown channel has nowhere safe to fall back to.
s := Shops{ChannelAndroid: {ShopID: "100700", SecretKey: "android-secret"}}
if got, ok := s.Shop(ChannelAndroid); !ok || got.ShopID != "100700" {
t.Errorf("Shop(android) = %q ok=%v, want the android shop", got.ShopID, ok)
}
if _, ok := s.Shop("ios"); ok {
t.Error("Shop(ios) resolved with no web shop configured")
}
if _, ok := (Shops{}).Shop(ChannelWeb); ok {
t.Error("an empty set resolved a shop")
}
}
func TestShopsByShopIDDoesNotGuess(t *testing.T) {
s := testShops()
channel, cfg, ok := s.ByShopID("100700")
if !ok || channel != ChannelAndroid || cfg.SecretKey != "android-secret" {
t.Errorf("ByShopID(100700) = %q/%q ok=%v, want the android shop", channel, cfg.SecretKey, ok)
}
// An unrecognised shop id means the notification was not meant for us; guessing at credentials
// would verify a foreign payment against our own shop.
for _, id := range []string{"999999", ""} {
if _, _, ok := s.ByShopID(id); ok {
t.Errorf("ByShopID(%q) resolved a shop", id)
}
}
}
func TestShopsIgnoreHalfConfiguredEntries(t *testing.T) {
s := Shops{
ChannelWeb: {ShopID: "100500"}, // no secret key
ChannelAndroid: {SecretKey: "android-secret"},
}
if s.Configured() {
t.Error("Configured() = true with no complete shop")
}
if _, ok := s.Shop(ChannelWeb); ok {
t.Error("Shop(web) resolved a shop with no secret key")
}
if _, _, ok := s.ByShopID("100500"); ok {
t.Error("ByShopID matched a shop with no secret key")
}
}
func TestShopsConfigured(t *testing.T) {
if !testShops().Configured() {
t.Error("Configured() = false with two complete shops")
}
if (Shops{}).Configured() {
t.Error("Configured() = true for an empty set")
}
}
func TestSingleItemReceiptTruncatesLongTitles(t *testing.T) {
long := strings.Repeat("ф", 200) // Cyrillic: truncation must count runes, not bytes
r := SingleItemReceipt("buyer@example.test", long, Amount{Value: "1.00", Currency: "RUB"}, VatCodeNone)
got := []rune(r.Items[0].Description)
if len(got) != maxDescriptionRunes {
t.Errorf("description = %d runes, want %d", len(got), maxDescriptionRunes)
}
for _, c := range got {
if c != 'ф' {
t.Fatalf("truncation cut a multi-byte rune: %q", string(got))
}
}
if r.Items[0].Quantity != 1 {
t.Errorf("quantity = %v, want 1 (a chip pack is bought once)", r.Items[0].Quantity)
}
}
func TestValidVatCode(t *testing.T) {
for _, code := range []int{1, 4, 11, 12} {
if !ValidVatCode(code) {
t.Errorf("vat code %d rejected, want accepted", code)
}
}
for _, code := range []int{0, -1, 13} {
if ValidVatCode(code) {
t.Errorf("vat code %d accepted, want rejected", code)
}
}
}
+271
View File
@@ -0,0 +1,271 @@
// Package yookassa talks to the YooKassa REST API for the direct-rail (RUB) payments: it creates a
// hosted payment the client is redirected to, re-reads a payment to confirm what really happened,
// and issues a refund. It is pure provider glue — no database, no payments-domain coupling — so the
// payments domain stays provider-agnostic and this layer is unit-testable in isolation.
//
// Two properties of the API shape everything here. First, YooKassa notifications carry NO signature:
// authenticity is established by re-reading the object with GetPayment (the notification body is a
// hint, never evidence) and, defensively, by the sender-IP allowlist in AllowedIP. Second, every
// state-changing call is made idempotent by an Idempotence-Key header, so a retried create or refund
// returns the original object instead of charging twice; callers pass the order id as that key.
//
// The order is threaded to the provider through metadata["order_id"] and echoed back on the payment,
// so a notification resolves to an order without trusting anything else in the body.
package yookassa
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
// DefaultBaseURL is the production API root. Config.BaseURL overrides it (tests point it at an
// httptest server).
const DefaultBaseURL = "https://api.yookassa.ru/v3"
// MetadataOrderID is the metadata key carrying our order id through the provider and back on the
// payment object — the only link a notification gives us to an order.
const MetadataOrderID = "order_id"
// Payment statuses (https://yookassa.ru/developers/payment-acceptance/getting-started/payment-process).
// A single-stage payment (Capture true) moves pending -> succeeded or pending -> canceled;
// StatusWaitingForCapture only occurs for two-stage payments, which this integration does not use.
const (
StatusPending = "pending"
StatusWaitingForCapture = "waiting_for_capture"
StatusSucceeded = "succeeded"
StatusCanceled = "canceled"
)
// ConfirmationRedirect is the confirmation scenario this integration uses: YooKassa hosts the
// payment form and returns the customer to the return URL afterwards.
const ConfirmationRedirect = "redirect"
// requestTimeout bounds a single API call. The caller's context still governs cancellation; this is
// the ceiling for a provider that has stopped answering.
const requestTimeout = 20 * time.Second
// maxResponseBytes caps how much of a response body is read, so a misbehaving or spoofed endpoint
// cannot exhaust memory.
const maxResponseBytes = 1 << 20
// apiClient is shared by every shop so connections pool across them. It carries only a ceiling
// timeout; per-request cancellation rides the context.
var apiClient = &http.Client{Timeout: requestTimeout}
// Config is a YooKassa merchant shop's credentials. ShopID and SecretKey authenticate every call as
// HTTP Basic credentials. IsTest records that these are a test shop's credentials, which lets the
// caller refuse to credit a live payment against a test shop and vice versa. BaseURL overrides
// DefaultBaseURL when set.
type Config struct {
ShopID string
SecretKey string
IsTest bool
BaseURL string
}
// Amount is a monetary amount on the wire: a decimal string with the fraction separator "." and an
// ISO-4217 currency code. payments.Money.Major() already renders Value in this form.
type Amount struct {
Value string `json:"value"`
Currency string `json:"currency"`
}
// Confirmation carries the confirmation scenario. ReturnURL is sent on a create (where the customer
// lands after paying, absolute); ConfirmationURL comes back on the created payment and is the page
// the customer must be sent to.
type Confirmation struct {
Type string `json:"type"`
ReturnURL string `json:"return_url,omitempty"`
ConfirmationURL string `json:"confirmation_url,omitempty"`
}
// Recipient identifies the shop that received the payment. AccountID is the shop id, which is how an
// incoming notification is attributed to one of several configured shops.
type Recipient struct {
AccountID string `json:"account_id"`
GatewayID string `json:"gateway_id"`
}
// Cancellation explains a canceled payment: which participant decided (yoo_money, payment_network,
// merchant) and why. It is reported to the operator, not to the payer.
type Cancellation struct {
Party string `json:"party"`
Reason string `json:"reason"`
}
// PaymentRequest is the body of a create-payment call in the redirect confirmation scenario. Capture
// true settles in one stage: a paid payment goes straight to succeeded with no capture call.
type PaymentRequest struct {
Amount Amount `json:"amount"`
Capture bool `json:"capture"`
Confirmation Confirmation `json:"confirmation"`
Description string `json:"description,omitempty"`
Metadata map[string]string `json:"metadata,omitempty"`
Receipt *Receipt `json:"receipt,omitempty"`
}
// Payment is a YooKassa payment object. Paid reports that the money was actually taken; Test is true
// for a payment made against a test shop. Metadata echoes what the create call sent, so
// Metadata[MetadataOrderID] identifies the order.
type Payment struct {
ID string `json:"id"`
Status string `json:"status"`
Paid bool `json:"paid"`
Test bool `json:"test"`
Amount Amount `json:"amount"`
Description string `json:"description"`
Confirmation Confirmation `json:"confirmation"`
Metadata map[string]string `json:"metadata"`
Recipient Recipient `json:"recipient"`
CancellationDetails *Cancellation `json:"cancellation_details,omitempty"`
}
// OrderID returns the order the payment funds, taken from the metadata the create call threaded
// through. It is empty for a payment we did not originate.
func (p Payment) OrderID() string { return p.Metadata[MetadataOrderID] }
// RefundRequest is the body of a create-refund call. Amount may be the whole payment or part of it;
// Receipt carries the refund receipt required when the shop registers receipts through YooKassa.
type RefundRequest struct {
PaymentID string `json:"payment_id"`
Amount Amount `json:"amount"`
Description string `json:"description,omitempty"`
Receipt *Receipt `json:"receipt,omitempty"`
}
// Refund is a YooKassa refund object. Its ID is distinct from the refunded payment's id and is what
// the ledger records as the refund's idempotency key.
type Refund struct {
ID string `json:"id"`
Status string `json:"status"`
PaymentID string `json:"payment_id"`
Amount Amount `json:"amount"`
}
// ErrUnconfigured is returned when a call is attempted on a shop with no credentials.
var ErrUnconfigured = errors.New("yookassa: shop is not configured")
// APIError is a non-2xx answer from the API. Description and Parameter come from YooKassa's error
// object and name the offending field, which is what makes a malformed receipt diagnosable.
type APIError struct {
StatusCode int `json:"-"`
Type string `json:"type"`
ID string `json:"id"`
Code string `json:"code"`
Description string `json:"description"`
Parameter string `json:"parameter"`
RetryAfter int `json:"retry_after"`
}
// Error renders the status together with YooKassa's own description, including the offending
// parameter when the API named one.
func (e *APIError) Error() string {
msg := e.Description
if msg == "" {
msg = e.Code
}
if e.Parameter != "" {
return fmt.Sprintf("yookassa: http %d: %s (parameter %s)", e.StatusCode, msg, e.Parameter)
}
return fmt.Sprintf("yookassa: http %d: %s", e.StatusCode, msg)
}
// Retryable reports whether repeating the call could succeed. A 5xx or a 429 is transient; a 4xx is
// a permanent rejection of this request. Callers use it to decide whether to ask the provider to
// redeliver a notification or to accept it as durably handled.
func (e *APIError) Retryable() bool {
return e.StatusCode >= 500 || e.StatusCode == http.StatusTooManyRequests
}
// Configured reports whether the shop has credentials to call the API with.
func (c Config) Configured() bool { return c.ShopID != "" && c.SecretKey != "" }
// CreatePayment opens a payment for the order and returns the created object, whose
// Confirmation.ConfirmationURL is the page the customer must be sent to. idempotenceKey makes the
// call safe to retry — YooKassa replays the original result for 24 hours — so callers pass the order
// id and never mint a second payment for the same order.
func (c Config) CreatePayment(ctx context.Context, req PaymentRequest, idempotenceKey string) (Payment, error) {
var out Payment
err := c.do(ctx, http.MethodPost, "/payments", req, idempotenceKey, &out)
return out, err
}
// GetPayment re-reads a payment by id. This is the authenticity check for the whole rail: YooKassa
// notifications are unsigned, so only the object returned here may be acted on.
func (c Config) GetPayment(ctx context.Context, paymentID string) (Payment, error) {
var out Payment
err := c.do(ctx, http.MethodGet, "/payments/"+url.PathEscape(paymentID), nil, "", &out)
return out, err
}
// CreateRefund returns money for a succeeded payment and reports the created refund. idempotenceKey
// guards against a double refund on a retry; callers pass the order id.
func (c Config) CreateRefund(ctx context.Context, req RefundRequest, idempotenceKey string) (Refund, error) {
var out Refund
err := c.do(ctx, http.MethodPost, "/refunds", req, idempotenceKey, &out)
return out, err
}
// do performs one authenticated API call and decodes the result into out. A non-2xx answer is
// returned as an *APIError carrying YooKassa's own error object when the body holds one.
func (c Config) do(ctx context.Context, method, path string, body any, idempotenceKey string, out any) error {
if !c.Configured() {
return ErrUnconfigured
}
var payload io.Reader
if body != nil {
buf, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("yookassa: encode %s %s: %w", method, path, err)
}
payload = bytes.NewReader(buf)
}
req, err := http.NewRequestWithContext(ctx, method, c.endpoint()+path, payload)
if err != nil {
return fmt.Errorf("yookassa: build %s %s: %w", method, path, err)
}
req.SetBasicAuth(c.ShopID, c.SecretKey)
req.Header.Set("Accept", "application/json")
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if idempotenceKey != "" {
req.Header.Set("Idempotence-Key", idempotenceKey)
}
resp, err := apiClient.Do(req)
if err != nil {
return fmt.Errorf("yookassa: %s %s: %w", method, path, err)
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes))
if err != nil {
return fmt.Errorf("yookassa: read %s %s: %w", method, path, err)
}
if resp.StatusCode < 200 || resp.StatusCode > 299 {
apiErr := &APIError{StatusCode: resp.StatusCode}
// The body is YooKassa's error object on a handled fault and something else entirely on an
// edge failure; either way the status alone is enough to classify the error.
_ = json.Unmarshal(raw, apiErr)
return apiErr
}
if err := json.Unmarshal(raw, out); err != nil {
return fmt.Errorf("yookassa: decode %s %s: %w", method, path, err)
}
return nil
}
// endpoint returns the API root for this shop, without a trailing slash.
func (c Config) endpoint() string {
if c.BaseURL == "" {
return DefaultBaseURL
}
return strings.TrimSuffix(c.BaseURL, "/")
}
+225
View File
@@ -0,0 +1,225 @@
package yookassa
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// capture records what the fake API received, so a test can assert on the request the client built.
type capture struct {
method string
path string
authUser string
authPass string
idempotenceKey string
body map[string]any
}
// fakeAPI serves one canned response and records the request. It returns a Config already pointed at
// it, which is how every API-shaped test drives the client without touching the network.
func fakeAPI(t *testing.T, status int, response string) (Config, *capture) {
t.Helper()
got := &capture{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got.method = r.Method
got.path = r.URL.Path
got.authUser, got.authPass, _ = r.BasicAuth()
got.idempotenceKey = r.Header.Get("Idempotence-Key")
if raw, _ := io.ReadAll(r.Body); len(raw) > 0 {
_ = json.Unmarshal(raw, &got.body)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_, _ = io.WriteString(w, response)
}))
t.Cleanup(srv.Close)
return Config{ShopID: "100500", SecretKey: "secret", BaseURL: srv.URL}, got
}
func TestCreatePaymentBuildsTheRequest(t *testing.T) {
cfg, got := fakeAPI(t, http.StatusOK, `{
"id":"23d93cac-000f-5000-8000-126628f15141","status":"pending","paid":false,"test":false,
"amount":{"value":"149.00","currency":"RUB"},
"confirmation":{"type":"redirect","confirmation_url":"https://yoomoney.ru/pay/abc"},
"metadata":{"order_id":"0197-order"}}`)
amount := Amount{Value: "149.00", Currency: "RUB"}
p, err := cfg.CreatePayment(context.Background(), PaymentRequest{
Amount: amount,
Capture: true,
Confirmation: Confirmation{Type: ConfirmationRedirect, ReturnURL: "https://example.test/pay/yookassa/return"},
Description: "10 chips",
Metadata: map[string]string{MetadataOrderID: "0197-order"},
Receipt: SingleItemReceipt("buyer@example.test", "10 chips", amount, VatCodeNone),
}, "0197-order")
if err != nil {
t.Fatalf("create payment: %v", err)
}
if got.method != http.MethodPost || got.path != "/payments" {
t.Errorf("request = %s %s, want POST /payments", got.method, got.path)
}
if got.authUser != "100500" || got.authPass != "secret" {
t.Errorf("basic auth = %q:%q, want the shop id and secret key", got.authUser, got.authPass)
}
// The idempotence key is what makes a retried create return the original payment instead of
// minting a second one for the same order.
if got.idempotenceKey != "0197-order" {
t.Errorf("Idempotence-Key = %q, want the order id", got.idempotenceKey)
}
if got.body["capture"] != true {
t.Errorf("capture = %v, want true (single-stage payment)", got.body["capture"])
}
conf, _ := got.body["confirmation"].(map[string]any)
if conf["type"] != ConfirmationRedirect || conf["return_url"] != "https://example.test/pay/yookassa/return" {
t.Errorf("confirmation = %v, want a redirect with the return url", conf)
}
meta, _ := got.body["metadata"].(map[string]any)
if meta[MetadataOrderID] != "0197-order" {
t.Errorf("metadata = %v, want the order id threaded through", meta)
}
receipt, _ := got.body["receipt"].(map[string]any)
customer, _ := receipt["customer"].(map[string]any)
if customer["email"] != "buyer@example.test" {
t.Errorf("receipt customer = %v, want the buyer email", customer)
}
items, _ := receipt["items"].([]any)
if len(items) != 1 {
t.Fatalf("receipt items = %d, want 1", len(items))
}
item, _ := items[0].(map[string]any)
if item["vat_code"] != float64(VatCodeNone) || item["payment_subject"] != PaymentSubjectService || item["payment_mode"] != PaymentModeFullPayment {
t.Errorf("receipt item fiscal attributes = %v", item)
}
if p.Confirmation.ConfirmationURL != "https://yoomoney.ru/pay/abc" {
t.Errorf("confirmation url = %q, want the hosted payment page", p.Confirmation.ConfirmationURL)
}
if p.OrderID() != "0197-order" {
t.Errorf("OrderID() = %q, want the order the payment funds", p.OrderID())
}
}
func TestGetPaymentReadsTheObject(t *testing.T) {
cfg, got := fakeAPI(t, http.StatusOK, `{
"id":"pay-1","status":"succeeded","paid":true,"test":true,
"amount":{"value":"149.00","currency":"RUB"},
"recipient":{"account_id":"100500","gateway_id":"100700"},
"metadata":{"order_id":"0197-order"}}`)
p, err := cfg.GetPayment(context.Background(), "pay-1")
if err != nil {
t.Fatalf("get payment: %v", err)
}
if got.method != http.MethodGet || got.path != "/payments/pay-1" {
t.Errorf("request = %s %s, want GET /payments/pay-1", got.method, got.path)
}
// A read is idempotent by nature, so no key is sent.
if got.idempotenceKey != "" {
t.Errorf("Idempotence-Key = %q, want none on a GET", got.idempotenceKey)
}
if p.Status != StatusSucceeded || !p.Paid {
t.Errorf("payment = %s paid=%v, want succeeded and paid", p.Status, p.Paid)
}
if !p.Test {
t.Error("Test = false, want true — a test-shop payment must be distinguishable from a live one")
}
if p.Recipient.AccountID != "100500" {
t.Errorf("recipient.account_id = %q, want the shop id", p.Recipient.AccountID)
}
}
func TestCreateRefundBuildsTheRequest(t *testing.T) {
cfg, got := fakeAPI(t, http.StatusOK, `{
"id":"refund-1","status":"succeeded","payment_id":"pay-1",
"amount":{"value":"149.00","currency":"RUB"}}`)
amount := Amount{Value: "149.00", Currency: "RUB"}
r, err := cfg.CreateRefund(context.Background(), RefundRequest{
PaymentID: "pay-1",
Amount: amount,
Receipt: SingleItemReceipt("buyer@example.test", "10 chips", amount, VatCodeNone),
}, "0197-order")
if err != nil {
t.Fatalf("create refund: %v", err)
}
if got.method != http.MethodPost || got.path != "/refunds" {
t.Errorf("request = %s %s, want POST /refunds", got.method, got.path)
}
if got.idempotenceKey != "0197-order" {
t.Errorf("Idempotence-Key = %q, want the order id (guards a double refund)", got.idempotenceKey)
}
if got.body["payment_id"] != "pay-1" {
t.Errorf("payment_id = %v, want the refunded payment", got.body["payment_id"])
}
if _, ok := got.body["receipt"]; !ok {
t.Error("refund carries no receipt — a refund receipt is required under «Чеки от ЮKassa»")
}
// The refund id is distinct from the payment id: the ledger keys the refund row on it, so the
// fund and refund rows can coexist under the same partial-unique index.
if r.ID != "refund-1" || r.PaymentID != "pay-1" {
t.Errorf("refund = %+v, want id refund-1 for payment pay-1", r)
}
}
func TestAPIErrorClassifiesRetryability(t *testing.T) {
cfg, _ := fakeAPI(t, http.StatusBadRequest,
`{"type":"error","id":"err-1","code":"invalid_request","description":"Receipt item is invalid","parameter":"receipt.items"}`)
_, err := cfg.GetPayment(context.Background(), "pay-1")
var apiErr *APIError
if !errors.As(err, &apiErr) {
t.Fatalf("error = %v, want an *APIError", err)
}
if apiErr.StatusCode != http.StatusBadRequest {
t.Errorf("status = %d, want 400", apiErr.StatusCode)
}
// The offending parameter is what makes a malformed receipt diagnosable rather than a mystery.
if !strings.Contains(apiErr.Error(), "receipt.items") {
t.Errorf("error text %q does not name the offending parameter", apiErr.Error())
}
if apiErr.Retryable() {
t.Error("a 400 reported as retryable; a permanent rejection must not be re-delivered")
}
for _, status := range []int{http.StatusTooManyRequests, http.StatusInternalServerError} {
cfg, _ := fakeAPI(t, status, `{"type":"error","description":"try later"}`)
_, err := cfg.GetPayment(context.Background(), "pay-1")
var e *APIError
if !errors.As(err, &e) || !e.Retryable() {
t.Errorf("status %d: error = %v, want a retryable *APIError", status, err)
}
}
}
func TestUnconfiguredShopMakesNoCall(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
t.Error("an unconfigured shop reached the API")
}))
t.Cleanup(srv.Close)
for name, cfg := range map[string]Config{
"no credentials": {BaseURL: srv.URL},
"no secret key": {ShopID: "100500", BaseURL: srv.URL},
"no shop id": {SecretKey: "secret", BaseURL: srv.URL},
} {
if _, err := cfg.GetPayment(context.Background(), "pay-1"); !errors.Is(err, ErrUnconfigured) {
t.Errorf("%s: error = %v, want ErrUnconfigured", name, err)
}
}
}
func TestEndpointDefaultsToTheProductionAPI(t *testing.T) {
if got := (Config{}).endpoint(); got != DefaultBaseURL {
t.Errorf("endpoint = %q, want %q", got, DefaultBaseURL)
}
if got := (Config{BaseURL: "https://api.test/v3/"}).endpoint(); got != "https://api.test/v3" {
t.Errorf("endpoint = %q, want the trailing slash trimmed", got)
}
}