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
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:
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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, "/")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user