Files
scrabble-game/backend/internal/yookassa/shops_test.go
T
Ilia Denisov 92ba527575
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
feat(payments): settle the direct rail through YooKassa
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.
2026-07-28 08:51:31 +02:00

113 lines
3.4 KiB
Go

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