// 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, "/") }