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
+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)
}
}