395a307eca
CI / changes (pull_request) Successful in 11s
CI / unit (pull_request) Successful in 22s
CI / integration (pull_request) Successful in 29s
CI / ui (pull_request) Successful in 1m27s
CI / conformance (pull_request) Successful in 19s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m52s
Two holes on the refund path, both found by asking what happens when a refund does not come from our own `/_gm` button. The merchant cabinet is a second entry point. An operator can refund there, and such a refund never passes through our API — so the money went back while the chips stayed credited, silently. Handle `refund.succeeded`: the refund is re-read from the API (the notification body is no more evidence here than it is for a payment), bound back to its order through the payment id recorded when the payment was minted, and reversed through the same engine. It is idempotent on (provider, refund id), so the event for a refund the console already recorded reverses nothing twice. The reversal engine is full-refund-only by design — it revokes exactly what the pack funded and rejects any other amount — so a partial refund is recorded as nothing at all and logged loudly for an operator. There is no non-arbitrary way to decide how many chips a part-refund costs, and guessing would be worse than asking a human. Second hole: a refund can still be canceled while pending, and the ledger is append-only. Recording on any non-empty refund id therefore risked revoking a customer's chips for money that stayed with us, with no way to take the row back. The console now records only a `succeeded` refund and tells the operator to press again otherwise — the idempotency key returns the same refund rather than paying twice. Tests: unit (GetRefund, the refund notification envelope, a non-final status surfaced to the caller); integration (a cabinet refund is reversed once and a redelivery is a no-op, the event after a console refund changes nothing, a partial refund records nothing, an unconfirmed refund reverses nothing, a pending refund records nothing until it settles and then does). The suite shares one database and the ledger dedupes refunds globally, so the fake provider now mints a refund id per payment — a constant id made one test's refund look like another's duplicate. Decisions D50 (amended) and D52; the notification subscription list in the deploy docs gains refund.succeeded.
281 lines
12 KiB
Go
281 lines
12 KiB
Go
// 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. The returned refund is not
|
|
// necessarily final — check Status before acting on it.
|
|
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
|
|
}
|
|
|
|
// GetRefund re-reads a refund by id. As with GetPayment this is the authenticity check: a refund
|
|
// notification is unsigned, so only the object returned here may be acted on.
|
|
func (c Config) GetRefund(ctx context.Context, refundID string) (Refund, error) {
|
|
var out Refund
|
|
err := c.do(ctx, http.MethodGet, "/refunds/"+url.PathEscape(refundID), nil, "", &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, "/")
|
|
}
|