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:
@@ -397,16 +397,24 @@ func (s *Store) Identities(ctx context.Context, accountID uuid.UUID) ([]Identity
|
||||
// HasConfirmedEmail reports whether the account owns a confirmed email identity — the direct-rail
|
||||
// recovery anchor a first purchase requires (D36).
|
||||
func (s *Store) HasConfirmedEmail(ctx context.Context, accountID uuid.UUID) (bool, error) {
|
||||
_, ok, err := s.ConfirmedEmail(ctx, accountID)
|
||||
return ok, err
|
||||
}
|
||||
|
||||
// ConfirmedEmail returns the account's confirmed email address, the oldest one first when several
|
||||
// exist. Besides being the direct-rail recovery anchor (D36) it is where the rail's fiscal receipt
|
||||
// is delivered, so the purchase path reads the address itself rather than only its presence.
|
||||
func (s *Store) ConfirmedEmail(ctx context.Context, accountID uuid.UUID) (string, bool, error) {
|
||||
ids, err := s.Identities(ctx, accountID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
return "", false, err
|
||||
}
|
||||
for _, id := range ids {
|
||||
if id.Kind == "email" && id.Confirmed {
|
||||
return true, nil
|
||||
return id.ExternalID, true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
// ListAccounts returns accounts for the admin user list, newest first, paginated
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"scrabble/backend/internal/robokassa"
|
||||
"scrabble/backend/internal/robot"
|
||||
"scrabble/backend/internal/telemetry"
|
||||
"scrabble/backend/internal/yookassa"
|
||||
)
|
||||
|
||||
// Config holds the backend's runtime configuration.
|
||||
@@ -65,8 +66,17 @@ type Config struct {
|
||||
// RendererURL is the base URL of the internal image-render sidecar (e.g.
|
||||
// http://renderer:8090). Empty disables the PNG export artifact.
|
||||
RendererURL string
|
||||
// Robokassa configures the direct-rail (RUB) payment provider — one merchant shop per channel
|
||||
// (D42). An empty set leaves the direct order and Result-callback endpoints unregistered.
|
||||
// YooKassa configures the direct-rail (RUB) payment provider — one merchant shop per channel
|
||||
// (D42). An empty set leaves the direct order and notification endpoints unregistered and falls
|
||||
// the direct rail back to Robokassa.
|
||||
YooKassa yookassa.Shops
|
||||
// YooKassaVatCode is the VAT rate code stamped on every fiscal receipt line (54-ФЗ tag 1199).
|
||||
// It is configurable because the rate is the one receipt attribute that genuinely changes.
|
||||
YooKassaVatCode int
|
||||
// Robokassa configures the retired direct-rail payment provider — one merchant shop per channel
|
||||
// (D42). It is dormant: no deployment sets its credentials, so the set is empty and the rail
|
||||
// resolves to YooKassa. Kept wired so restoring Robokassa is a credentials change, not a code
|
||||
// change — see backend/internal/robokassa/README.md.
|
||||
Robokassa robokassa.Shops
|
||||
}
|
||||
|
||||
@@ -158,9 +168,26 @@ func Load() (Config, error) {
|
||||
AdminTo: os.Getenv("BACKEND_ADMIN_EMAIL"),
|
||||
}
|
||||
|
||||
// Robokassa direct rail: one merchant shop per channel (D42). The legacy single-shop vars seed
|
||||
// the web channel so existing deploys keep working; the per-channel vars add the rest. A shop
|
||||
// with no MerchantLogin is dropped (the rail stays dormant when none is configured).
|
||||
// YooKassa direct rail: one merchant shop per channel (D42). A shop missing either credential is
|
||||
// dropped, so the rail stays dormant until a channel is fully configured.
|
||||
ykShops := yookassa.Shops{}
|
||||
for channel, prefix := range map[string]string{
|
||||
yookassa.ChannelWeb: "BACKEND_YOOKASSA_WEB",
|
||||
yookassa.ChannelAndroid: "BACKEND_YOOKASSA_ANDROID",
|
||||
} {
|
||||
if shop := yookassaShop(prefix); shop.Configured() {
|
||||
ykShops[channel] = shop
|
||||
}
|
||||
}
|
||||
vatCode, err := envInt("BACKEND_YOOKASSA_VAT_CODE", yookassa.VatCodeNone)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
// Robokassa direct rail: retired but kept wired (see backend/internal/robokassa/README.md). No
|
||||
// deployment sets these, so the set is empty and the direct rail resolves to YooKassa; restoring
|
||||
// the credentials revives it without a code change. The legacy single-shop vars seed the web
|
||||
// channel; the per-channel vars add the rest.
|
||||
shops := robokassa.Shops{}
|
||||
web := robokassaShop("BACKEND_ROBOKASSA_WEB")
|
||||
if web.MerchantLogin == "" {
|
||||
@@ -190,6 +217,8 @@ func Load() (Config, error) {
|
||||
GuestRetention: guestRetention,
|
||||
ExportSignKey: os.Getenv("BACKEND_EXPORT_SIGN_KEY"),
|
||||
RendererURL: os.Getenv("BACKEND_RENDERER_URL"),
|
||||
YooKassa: ykShops,
|
||||
YooKassaVatCode: vatCode,
|
||||
Robokassa: shops,
|
||||
}
|
||||
if err := c.validate(); err != nil {
|
||||
@@ -248,6 +277,19 @@ func (c Config) validate() error {
|
||||
return fmt.Errorf("config: robokassa shop %q: password1 and password2 must be set when its merchant login is", channel)
|
||||
}
|
||||
}
|
||||
if !yookassa.ValidVatCode(c.YooKassaVatCode) {
|
||||
return fmt.Errorf("config: BACKEND_YOOKASSA_VAT_CODE %d is not a 54-ФЗ VAT rate code (1..%d)", c.YooKassaVatCode, yookassa.VatCodeMax)
|
||||
}
|
||||
if c.YooKassa.Configured() {
|
||||
// The YooKassa rail sends the customer to a hosted payment page and needs an absolute return
|
||||
// URL to bring them back, which only the public base URL can supply.
|
||||
if c.PublicBaseURL == "" {
|
||||
return fmt.Errorf("config: BACKEND_PUBLIC_BASE_URL must be set when a YooKassa shop is configured")
|
||||
}
|
||||
if u, err := url.Parse(c.PublicBaseURL); err != nil || u.Scheme == "" || u.Host == "" {
|
||||
return fmt.Errorf("config: BACKEND_PUBLIC_BASE_URL %q must be an absolute URL (scheme://host)", c.PublicBaseURL)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -288,6 +330,18 @@ func envDuration(key string, fallback time.Duration) (time.Duration, error) {
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// yookassaShop reads a YooKassa shop's credentials from the environment under prefix (e.g.
|
||||
// "BACKEND_YOOKASSA_WEB" → _SHOP_ID / _SECRET_KEY / _TEST). _TEST marks a test shop, which lets the
|
||||
// intake refuse to credit a live payment against test credentials and vice versa. A shop missing
|
||||
// either credential yields a Config the caller drops.
|
||||
func yookassaShop(prefix string) yookassa.Config {
|
||||
return yookassa.Config{
|
||||
ShopID: os.Getenv(prefix + "_SHOP_ID"),
|
||||
SecretKey: os.Getenv(prefix + "_SECRET_KEY"),
|
||||
IsTest: os.Getenv(prefix+"_TEST") == "1",
|
||||
}
|
||||
}
|
||||
|
||||
// robokassaShop reads a Robokassa shop's four credentials from the environment under prefix (e.g.
|
||||
// "BACKEND_ROBOKASSA_WEB" → _MERCHANT_LOGIN / _PASSWORD1 / _PASSWORD2 / _TEST). A missing
|
||||
// MerchantLogin yields a zero Config the caller drops.
|
||||
|
||||
@@ -0,0 +1,504 @@
|
||||
//go:build integration
|
||||
|
||||
package inttest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"scrabble/backend/internal/account"
|
||||
"scrabble/backend/internal/payments"
|
||||
"scrabble/backend/internal/server"
|
||||
"scrabble/backend/internal/yookassa"
|
||||
)
|
||||
|
||||
// The test shop the fake API answers for. IsTest is true throughout, matching a YooKassa test shop.
|
||||
const (
|
||||
ykShopID = "100500"
|
||||
ykSecretKey = "test_secret"
|
||||
// ykSenderIP is inside YooKassa's published notification ranges; the intake rejects anything else.
|
||||
ykSenderIP = "185.71.76.1"
|
||||
)
|
||||
|
||||
// fakeYooKassa is a stand-in for the YooKassa API. Tests set the payment it reports and whether a
|
||||
// refund succeeds, then assert on what the intake did with that answer.
|
||||
type fakeYooKassa struct {
|
||||
mu sync.Mutex
|
||||
// payments answers GET /payments/{id}; a missing id is a 404, as it is for a payment we never made.
|
||||
payments map[string]yookassa.Payment
|
||||
// refundFails makes POST /refunds answer 500, standing in for a provider that did not move money.
|
||||
refundFails bool
|
||||
// refundCalls counts the refunds actually requested.
|
||||
refundCalls int
|
||||
// createdIdempotenceKey records the key sent on the last create-payment call.
|
||||
createdIdempotenceKey string
|
||||
// lastReceipt records the receipt object of the last create-payment call.
|
||||
lastReceipt map[string]any
|
||||
}
|
||||
|
||||
// newFakeYooKassa starts the fake API and returns it with a shop config pointed at it.
|
||||
func newFakeYooKassa(t *testing.T) (*fakeYooKassa, yookassa.Config) {
|
||||
t.Helper()
|
||||
f := &fakeYooKassa{payments: map[string]yookassa.Payment{}}
|
||||
srv := httptest.NewServer(http.HandlerFunc(f.serve))
|
||||
t.Cleanup(srv.Close)
|
||||
return f, yookassa.Config{ShopID: ykShopID, SecretKey: ykSecretKey, IsTest: true, BaseURL: srv.URL}
|
||||
}
|
||||
|
||||
func (f *fakeYooKassa) serve(w http.ResponseWriter, r *http.Request) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/payments":
|
||||
var body struct {
|
||||
Amount yookassa.Amount `json:"amount"`
|
||||
Metadata map[string]string `json:"metadata"`
|
||||
Receipt map[string]any `json:"receipt"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
f.createdIdempotenceKey = r.Header.Get("Idempotence-Key")
|
||||
f.lastReceipt = body.Receipt
|
||||
id := "pay-" + body.Metadata[yookassa.MetadataOrderID]
|
||||
p := yookassa.Payment{
|
||||
ID: id, Status: yookassa.StatusPending, Test: true, Amount: body.Amount,
|
||||
Confirmation: yookassa.Confirmation{Type: yookassa.ConfirmationRedirect, ConfirmationURL: "https://yoomoney.test/pay/" + id},
|
||||
Metadata: body.Metadata,
|
||||
Recipient: yookassa.Recipient{AccountID: ykShopID},
|
||||
}
|
||||
f.payments[id] = p
|
||||
_ = json.NewEncoder(w).Encode(p)
|
||||
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/payments/"):
|
||||
p, ok := f.payments[strings.TrimPrefix(r.URL.Path, "/payments/")]
|
||||
if !ok {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = w.Write([]byte(`{"type":"error","description":"not found"}`))
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(p)
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/refunds":
|
||||
if f.refundFails {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte(`{"type":"error","description":"acquirer unavailable"}`))
|
||||
return
|
||||
}
|
||||
f.refundCalls++
|
||||
var body yookassa.RefundRequest
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
_ = json.NewEncoder(w).Encode(yookassa.Refund{
|
||||
ID: "refund-1", Status: yookassa.StatusSucceeded, PaymentID: body.PaymentID, Amount: body.Amount,
|
||||
})
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
// setPayment overwrites what the API reports for a payment — how a test says "the money did (or did
|
||||
// not) really move", independently of what a notification claims.
|
||||
func (f *fakeYooKassa) setPayment(id string, mutate func(p *yookassa.Payment)) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
p := f.payments[id]
|
||||
p.ID = id
|
||||
mutate(&p)
|
||||
f.payments[id] = p
|
||||
}
|
||||
|
||||
// yookassaServer builds a backend server whose direct rail is the fake YooKassa shop.
|
||||
//
|
||||
// The suite shares one database, and the kill-switch test leaves the direct rail disabled, so the
|
||||
// rail status is reset first: these tests are about the provider, not about ops availability, and
|
||||
// fail-open means an absent row is an enabled rail.
|
||||
func yookassaServer(t *testing.T, shop yookassa.Config) (*server.Server, *payments.Service) {
|
||||
t.Helper()
|
||||
if _, err := testDB.ExecContext(context.Background(),
|
||||
`DELETE FROM payments.rail_status WHERE rail LIKE 'direct%'`); err != nil {
|
||||
t.Fatalf("reset direct rail status: %v", err)
|
||||
}
|
||||
paySvc := newPaymentsService()
|
||||
srv := server.New(":0", server.Deps{
|
||||
Logger: zap.NewNop(),
|
||||
Accounts: account.NewStore(testDB),
|
||||
Games: newGameService(),
|
||||
Registry: testRegistry,
|
||||
DictDir: dictDir(),
|
||||
Payments: paySvc,
|
||||
YooKassa: yookassa.Shops{yookassa.ChannelWeb: shop},
|
||||
YooKassaVatCode: yookassa.VatCodeNone,
|
||||
PublicBaseURL: "https://scrabble.test",
|
||||
})
|
||||
return srv, paySvc
|
||||
}
|
||||
|
||||
// postYooKassaNotify posts a notification body to the intake as the given sender, and reports the status code.
|
||||
func postYooKassaNotify(t *testing.T, srv *server.Server, senderIP, event, paymentID, orderID string) int {
|
||||
t.Helper()
|
||||
body := fmt.Sprintf(`{"type":"notification","event":%q,"object":{"id":%q,"status":"succeeded",
|
||||
"recipient":{"account_id":%q},"metadata":{"order_id":%q}}}`, event, paymentID, ykShopID, orderID)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/internal/payments/yookassa/notify", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Forwarded-For", senderIP)
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Handler().ServeHTTP(rec, req)
|
||||
return rec.Code
|
||||
}
|
||||
|
||||
// seedYooKassaOrder opens a paid-for-real order: a pending order with the provider payment id
|
||||
// attached, exactly as the order path leaves it once YooKassa has minted the payment.
|
||||
func seedYooKassaOrder(t *testing.T, f *fakeYooKassa, pay *payments.Service, acc uuid.UUID) (uuid.UUID, string) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
prod := seedPackProduct(t, 100, methodPrice{method: "direct", currency: "RUB", amount: 14900})
|
||||
res, err := pay.CreateOrder(ctx, acc, payments.NewContext("direct", "web"), []payments.Source{payments.SourceDirect}, prod, "yookassa")
|
||||
if err != nil {
|
||||
t.Fatalf("create order: %v", err)
|
||||
}
|
||||
paymentID := "pay-" + res.OrderID.String()
|
||||
f.setPayment(paymentID, func(p *yookassa.Payment) {
|
||||
p.Status = yookassa.StatusSucceeded
|
||||
p.Paid = true
|
||||
p.Test = true
|
||||
p.Amount = yookassa.Amount{Value: "149.00", Currency: "RUB"}
|
||||
p.Metadata = map[string]string{yookassa.MetadataOrderID: res.OrderID.String()}
|
||||
p.Recipient = yookassa.Recipient{AccountID: ykShopID}
|
||||
})
|
||||
if err := pay.AttachProviderPayment(ctx, res.OrderID, "yookassa", paymentID); err != nil {
|
||||
t.Fatalf("attach provider payment: %v", err)
|
||||
}
|
||||
return res.OrderID, paymentID
|
||||
}
|
||||
|
||||
// TestYooKassaNotifyCreditsOnce drives the crediting path: a succeeded notification credits the
|
||||
// order exactly once, and a redelivery credits nothing more.
|
||||
func TestYooKassaNotifyCreditsOnce(t *testing.T) {
|
||||
f, shop := newFakeYooKassa(t)
|
||||
srv, pay := yookassaServer(t, shop)
|
||||
acc := provisionAccount(t)
|
||||
orderID, paymentID := seedYooKassaOrder(t, f, pay, acc)
|
||||
|
||||
if code := postYooKassaNotify(t, srv, ykSenderIP, yookassa.EventPaymentSucceeded, paymentID, orderID.String()); code != http.StatusOK {
|
||||
t.Fatalf("notify = %d, want 200", code)
|
||||
}
|
||||
if got := readBalance(t, acc, "direct"); got != 100 {
|
||||
t.Errorf("balance = %d, want 100", got)
|
||||
}
|
||||
if orderStatus(t, orderID) != "paid" {
|
||||
t.Errorf("order status = %s, want paid", orderStatus(t, orderID))
|
||||
}
|
||||
|
||||
// YooKassa redelivers until it gets a 200; a redelivery must credit nothing and still answer 200.
|
||||
if code := postYooKassaNotify(t, srv, ykSenderIP, yookassa.EventPaymentSucceeded, paymentID, orderID.String()); code != http.StatusOK {
|
||||
t.Fatalf("redelivered notify = %d, want 200", code)
|
||||
}
|
||||
if got := readBalance(t, acc, "direct"); got != 100 {
|
||||
t.Errorf("balance after redelivery = %d, want 100 (credited once)", got)
|
||||
}
|
||||
if ledgerRows(t, acc, "fund") != 1 {
|
||||
t.Errorf("fund ledger rows = %d, want 1", ledgerRows(t, acc, "fund"))
|
||||
}
|
||||
}
|
||||
|
||||
// TestYooKassaNotifyIsNotEvidence is the security property of the whole rail: notifications are
|
||||
// unsigned, so a fabricated "succeeded" credits nothing when the confirming read says otherwise.
|
||||
func TestYooKassaNotifyIsNotEvidence(t *testing.T) {
|
||||
f, shop := newFakeYooKassa(t)
|
||||
srv, pay := yookassaServer(t, shop)
|
||||
acc := provisionAccount(t)
|
||||
orderID, paymentID := seedYooKassaOrder(t, f, pay, acc)
|
||||
|
||||
// The provider says the payment never completed, whatever the notification claims.
|
||||
f.setPayment(paymentID, func(p *yookassa.Payment) { p.Status = yookassa.StatusPending; p.Paid = false })
|
||||
if code := postYooKassaNotify(t, srv, ykSenderIP, yookassa.EventPaymentSucceeded, paymentID, orderID.String()); code != http.StatusOK {
|
||||
t.Fatalf("notify = %d, want 200", code)
|
||||
}
|
||||
if got := readBalance(t, acc, "direct"); got != 0 {
|
||||
t.Errorf("balance = %d, want 0 — a forged notification credited chips", got)
|
||||
}
|
||||
|
||||
// A payment we never made: the confirming read 404s, so there is nothing to credit.
|
||||
if code := postYooKassaNotify(t, srv, ykSenderIP, yookassa.EventPaymentSucceeded, "pay-invented", orderID.String()); code != http.StatusOK {
|
||||
t.Fatalf("notify for an unknown payment = %d, want 200", code)
|
||||
}
|
||||
if got := readBalance(t, acc, "direct"); got != 0 {
|
||||
t.Errorf("balance = %d, want 0 — an invented payment credited chips", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestYooKassaNotifyRejectsForeignSenders checks the sender allowlist: an address outside YooKassa's
|
||||
// published ranges is refused before any provider call, so a forger cannot make us fan out requests.
|
||||
func TestYooKassaNotifyRejectsForeignSenders(t *testing.T) {
|
||||
f, shop := newFakeYooKassa(t)
|
||||
srv, pay := yookassaServer(t, shop)
|
||||
acc := provisionAccount(t)
|
||||
orderID, paymentID := seedYooKassaOrder(t, f, pay, acc)
|
||||
|
||||
if code := postYooKassaNotify(t, srv, "8.8.8.8", yookassa.EventPaymentSucceeded, paymentID, orderID.String()); code != http.StatusForbidden {
|
||||
t.Fatalf("notify from a foreign address = %d, want 403", code)
|
||||
}
|
||||
if got := readBalance(t, acc, "direct"); got != 0 {
|
||||
t.Errorf("balance = %d, want 0 — a foreign sender credited chips", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestYooKassaNotifyRejectsLiveOnTestShop is the guard that keeps play money out of a live ledger and
|
||||
// real money out of a test one: a payment whose test flag disagrees with the shop's is not credited.
|
||||
func TestYooKassaNotifyRejectsLiveOnTestShop(t *testing.T) {
|
||||
f, shop := newFakeYooKassa(t)
|
||||
srv, pay := yookassaServer(t, shop) // the shop is a test shop
|
||||
acc := provisionAccount(t)
|
||||
orderID, paymentID := seedYooKassaOrder(t, f, pay, acc)
|
||||
|
||||
f.setPayment(paymentID, func(p *yookassa.Payment) { p.Test = false }) // a live payment
|
||||
if code := postYooKassaNotify(t, srv, ykSenderIP, yookassa.EventPaymentSucceeded, paymentID, orderID.String()); code != http.StatusOK {
|
||||
t.Fatalf("notify = %d, want 200", code)
|
||||
}
|
||||
if got := readBalance(t, acc, "direct"); got != 0 {
|
||||
t.Errorf("balance = %d, want 0 — a live payment credited against test credentials", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestYooKassaCanceledRecordsFailure checks an active decline records a failed payment event and
|
||||
// credits nothing, so the customer learns the attempt did not go through.
|
||||
func TestYooKassaCanceledRecordsFailure(t *testing.T) {
|
||||
f, shop := newFakeYooKassa(t)
|
||||
srv, pay := yookassaServer(t, shop)
|
||||
acc := provisionAccount(t)
|
||||
orderID, paymentID := seedYooKassaOrder(t, f, pay, acc)
|
||||
|
||||
f.setPayment(paymentID, func(p *yookassa.Payment) {
|
||||
p.Status = yookassa.StatusCanceled
|
||||
p.Paid = false
|
||||
p.CancellationDetails = &yookassa.Cancellation{Party: "payment_network", Reason: "insufficient_funds"}
|
||||
})
|
||||
if code := postYooKassaNotify(t, srv, ykSenderIP, yookassa.EventPaymentCanceled, paymentID, orderID.String()); code != http.StatusOK {
|
||||
t.Fatalf("notify = %d, want 200", code)
|
||||
}
|
||||
if got := readBalance(t, acc, "direct"); got != 0 {
|
||||
t.Errorf("balance = %d, want 0 — a canceled payment credited chips", got)
|
||||
}
|
||||
if orderStatus(t, orderID) != "pending" {
|
||||
t.Errorf("order status = %s, want pending (a decline does not settle the order)", orderStatus(t, orderID))
|
||||
}
|
||||
evs, err := pay.UndispatchedEvents(context.Background(), 50)
|
||||
if err != nil {
|
||||
t.Fatalf("read events: %v", err)
|
||||
}
|
||||
found := false
|
||||
for _, e := range evs {
|
||||
if e.AccountID == acc && e.Type == "failed" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("no failed payment event recorded for a declined payment")
|
||||
}
|
||||
}
|
||||
|
||||
// TestYooKassaReconcileCreditsLostNotification is the safety net: when no notification ever arrives,
|
||||
// the expiry-time check asks the provider and credits an order that was in fact paid.
|
||||
func TestYooKassaReconcileCreditsLostNotification(t *testing.T) {
|
||||
f, shop := newFakeYooKassa(t)
|
||||
srv, pay := yookassaServer(t, shop)
|
||||
acc := provisionAccount(t)
|
||||
orderID, _ := seedYooKassaOrder(t, f, pay, acc)
|
||||
|
||||
// Age the order past its lifetime without any notification having been delivered.
|
||||
if _, err := testDB.ExecContext(context.Background(),
|
||||
`UPDATE payments.orders SET created_at = now() - interval '2 days' WHERE order_id=$1`, orderID); err != nil {
|
||||
t.Fatalf("age order: %v", err)
|
||||
}
|
||||
if n := srv.ReconcileYooKassaOrders(context.Background()); n != 1 {
|
||||
t.Fatalf("reconciled %d orders, want 1", n)
|
||||
}
|
||||
if got := readBalance(t, acc, "direct"); got != 100 {
|
||||
t.Errorf("balance = %d, want 100 — the safety net did not credit a paid order", got)
|
||||
}
|
||||
if orderStatus(t, orderID) != "paid" {
|
||||
t.Errorf("order status = %s, want paid", orderStatus(t, orderID))
|
||||
}
|
||||
// A second sweep finds nothing left to do — the order is no longer pending.
|
||||
if n := srv.ReconcileYooKassaOrders(context.Background()); n != 0 {
|
||||
t.Errorf("second sweep credited %d orders, want 0", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestYooKassaReconcileLeavesUnpaidOrders checks the sweep does not invent a credit for an order the
|
||||
// customer abandoned.
|
||||
func TestYooKassaReconcileLeavesUnpaidOrders(t *testing.T) {
|
||||
f, shop := newFakeYooKassa(t)
|
||||
srv, pay := yookassaServer(t, shop)
|
||||
acc := provisionAccount(t)
|
||||
orderID, paymentID := seedYooKassaOrder(t, f, pay, acc)
|
||||
f.setPayment(paymentID, func(p *yookassa.Payment) { p.Status = yookassa.StatusCanceled; p.Paid = false })
|
||||
|
||||
if _, err := testDB.ExecContext(context.Background(),
|
||||
`UPDATE payments.orders SET created_at = now() - interval '2 days' WHERE order_id=$1`, orderID); err != nil {
|
||||
t.Fatalf("age order: %v", err)
|
||||
}
|
||||
if n := srv.ReconcileYooKassaOrders(context.Background()); n != 0 {
|
||||
t.Errorf("reconciled %d orders, want 0 (the payment was never completed)", n)
|
||||
}
|
||||
if got := readBalance(t, acc, "direct"); got != 0 {
|
||||
t.Errorf("balance = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestYooKassaConsoleRefundMovesMoneyThenRecords drives the operator refund end to end: the provider
|
||||
// refund is requested first and the ledger records the provider's own refund id.
|
||||
func TestYooKassaConsoleRefundMovesMoneyThenRecords(t *testing.T) {
|
||||
f, shop := newFakeYooKassa(t)
|
||||
srv, pay := yookassaServer(t, shop)
|
||||
acc := provisionAccount(t)
|
||||
orderID, paymentID := seedYooKassaOrder(t, f, pay, acc)
|
||||
if code := postYooKassaNotify(t, srv, ykSenderIP, yookassa.EventPaymentSucceeded, paymentID, orderID.String()); code != http.StatusOK {
|
||||
t.Fatalf("notify = %d, want 200", code)
|
||||
}
|
||||
|
||||
h := srv.Handler()
|
||||
base := "http://admin.test/_gm/users/" + acc.String()
|
||||
code, body := consoleDo(h, http.MethodPost, base+"/refund", "order_id="+orderID.String(), "http://admin.test")
|
||||
if code != http.StatusOK || !strings.Contains(body, "revoked 100 chips") {
|
||||
t.Fatalf("refund = %d, body has 'revoked 100 chips' = %v", code, strings.Contains(body, "revoked 100 chips"))
|
||||
}
|
||||
if f.refundCalls != 1 {
|
||||
t.Errorf("provider refunds requested = %d, want 1", f.refundCalls)
|
||||
}
|
||||
if got := readBalance(t, acc, "direct"); got != 0 {
|
||||
t.Errorf("balance after refund = %d, want 0", got)
|
||||
}
|
||||
// The ledger records the provider's own refund id, which is what keeps it reconcilable against
|
||||
// YooKassa's records — and is distinct from the payment id, so both rows coexist.
|
||||
var provider, refundID string
|
||||
if err := testDB.QueryRowContext(context.Background(),
|
||||
`SELECT provider, provider_payment_id FROM payments.ledger WHERE account_id=$1 AND kind='refund'`,
|
||||
acc).Scan(&provider, &refundID); err != nil {
|
||||
t.Fatalf("read refund ledger row: %v", err)
|
||||
}
|
||||
if provider != "yookassa" || refundID != "refund-1" {
|
||||
t.Errorf("refund ledger row = (%s, %s), want (yookassa, refund-1)", provider, refundID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestYooKassaRefundRecordsNothingWhenTheProviderFails is the invariant that keeps the ledger honest:
|
||||
// if the money did not move, nothing is written.
|
||||
func TestYooKassaRefundRecordsNothingWhenTheProviderFails(t *testing.T) {
|
||||
f, shop := newFakeYooKassa(t)
|
||||
srv, pay := yookassaServer(t, shop)
|
||||
acc := provisionAccount(t)
|
||||
orderID, paymentID := seedYooKassaOrder(t, f, pay, acc)
|
||||
if code := postYooKassaNotify(t, srv, ykSenderIP, yookassa.EventPaymentSucceeded, paymentID, orderID.String()); code != http.StatusOK {
|
||||
t.Fatalf("notify = %d, want 200", code)
|
||||
}
|
||||
|
||||
f.refundFails = true
|
||||
h := srv.Handler()
|
||||
base := "http://admin.test/_gm/users/" + acc.String()
|
||||
_, body := consoleDo(h, http.MethodPost, base+"/refund", "order_id="+orderID.String(), "http://admin.test")
|
||||
if !strings.Contains(body, "nothing was recorded") {
|
||||
t.Errorf("refund failure message = %q, want it to say nothing was recorded", body)
|
||||
}
|
||||
if got := readBalance(t, acc, "direct"); got != 100 {
|
||||
t.Errorf("balance = %d, want 100 — chips were revoked without the money moving", got)
|
||||
}
|
||||
if ledgerRows(t, acc, "refund") != 0 {
|
||||
t.Error("a refund ledger row was written although the provider did not refund")
|
||||
}
|
||||
}
|
||||
|
||||
// TestYooKassaOrderMintsAPaymentWithAReceipt drives the purchase path over HTTP: the order endpoint
|
||||
// calls the provider, threads the order id through, sends the fiscal receipt to the buyer's confirmed
|
||||
// address, and hands the client the hosted payment page.
|
||||
func TestYooKassaOrderMintsAPaymentWithAReceipt(t *testing.T) {
|
||||
f, shop := newFakeYooKassa(t)
|
||||
srv, _ := yookassaServer(t, shop)
|
||||
acc := provisionAccount(t)
|
||||
const email = "buyer@example.test"
|
||||
if _, err := testDB.ExecContext(context.Background(),
|
||||
`INSERT INTO backend.identities (identity_id, account_id, kind, external_id, confirmed) VALUES ($1,$2,'email',$3,true)`,
|
||||
uuid.New(), acc, email); err != nil {
|
||||
t.Fatalf("seed email identity: %v", err)
|
||||
}
|
||||
prod := seedPackProduct(t, 100, methodPrice{method: "direct", currency: "RUB", amount: 14900})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/user/wallet/order",
|
||||
strings.NewReader(fmt.Sprintf(`{"product_id":%q}`, prod)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-User-ID", acc.String())
|
||||
req.Header.Set("X-Platform", "direct/web")
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Handler().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("order = %d, want 200: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var out struct {
|
||||
OrderID string `json:"order_id"`
|
||||
RedirectURL string `json:"redirect_url"`
|
||||
Rail string `json:"rail"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||
t.Fatalf("decode order response: %v", err)
|
||||
}
|
||||
if out.Rail != "yookassa" || !strings.HasPrefix(out.RedirectURL, "https://yoomoney.test/pay/") {
|
||||
t.Errorf("order response = %+v, want the yookassa rail and its hosted payment page", out)
|
||||
}
|
||||
// The order id is both the idempotency key (so a retried create cannot mint a second payment)
|
||||
// and the metadata that later resolves a notification to this order.
|
||||
if f.createdIdempotenceKey != out.OrderID {
|
||||
t.Errorf("Idempotence-Key = %q, want the order id %q", f.createdIdempotenceKey, out.OrderID)
|
||||
}
|
||||
customer, _ := f.lastReceipt["customer"].(map[string]any)
|
||||
if customer["email"] != email {
|
||||
t.Errorf("receipt customer = %v, want the confirmed email %q", customer, email)
|
||||
}
|
||||
items, _ := f.lastReceipt["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(yookassa.VatCodeNone) || item["payment_subject"] != yookassa.PaymentSubjectService {
|
||||
t.Errorf("receipt fiscal attributes = %v", item)
|
||||
}
|
||||
// The payment id is recorded on the order, which is what the safety net and a refund need.
|
||||
orderID, err := uuid.Parse(out.OrderID)
|
||||
if err != nil {
|
||||
t.Fatalf("parse order id: %v", err)
|
||||
}
|
||||
var paymentID string
|
||||
if err := testDB.QueryRowContext(context.Background(),
|
||||
`SELECT provider_payment_id FROM payments.orders WHERE order_id=$1`, orderID).Scan(&paymentID); err != nil {
|
||||
t.Fatalf("read order payment id: %v", err)
|
||||
}
|
||||
if paymentID != "pay-"+out.OrderID {
|
||||
t.Errorf("order provider_payment_id = %q, want the minted payment id", paymentID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestYooKassaOrderRequiresAnEmailAnchor keeps D36 in force on the new rail: without a confirmed
|
||||
// address there is no recovery anchor and nowhere to deliver the fiscal receipt, so no payment is
|
||||
// minted at all.
|
||||
func TestYooKassaOrderRequiresAnEmailAnchor(t *testing.T) {
|
||||
_, shop := newFakeYooKassa(t)
|
||||
srv, _ := yookassaServer(t, shop)
|
||||
acc := provisionAccount(t) // a telegram identity only — no confirmed email
|
||||
prod := seedPackProduct(t, 100, methodPrice{method: "direct", currency: "RUB", amount: 14900})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/user/wallet/order",
|
||||
strings.NewReader(fmt.Sprintf(`{"product_id":%q}`, prod)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-User-ID", acc.String())
|
||||
req.Header.Set("X-Platform", "direct/web")
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Handler().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusForbidden || !strings.Contains(rec.Body.String(), "email_required") {
|
||||
t.Fatalf("order = %d (%s), want 403 email_required", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -11,11 +11,24 @@ import (
|
||||
// one manual full refund per order.
|
||||
const providerAdmin = "admin"
|
||||
|
||||
// RefundOrderFull refunds a paid order in full at the operator's request: it revokes the funded
|
||||
// chips best-effort (floored at 0, never negative — D27), records a refund ledger row, and is
|
||||
// idempotent (a second call reports AlreadyRefunded). The operator performs the actual money refund
|
||||
// on the rail (Robokassa cabinet / VK support / Telegram refundStarPayment); this records it.
|
||||
// RefundOrderFull refunds a paid order in full at the operator's request, recording the reversal
|
||||
// only. It is for the rails that have no refund API of our own to call: the operator performs the
|
||||
// actual money refund there by hand (VK support, Telegram refundStarPayment, the Robokassa cabinet),
|
||||
// and this records it under the operator's own idempotency key.
|
||||
func (s *Service) RefundOrderFull(ctx context.Context, orderID uuid.UUID) (RefundOutcome, error) {
|
||||
return s.RefundOrderFullAs(ctx, orderID, providerAdmin, orderID.String())
|
||||
}
|
||||
|
||||
// RefundOrderFullAs refunds a paid order in full and records it under the given provider and refund
|
||||
// id: it revokes the funded chips best-effort (floored at 0, never negative — D27), appends a refund
|
||||
// ledger row and is idempotent on (provider, providerRefundID), so a second call reports
|
||||
// AlreadyRefunded. Callers whose rail moved the money through an API pass that rail's own refund id,
|
||||
// which keeps the ledger reconcilable against the provider's records; the refund id is distinct from
|
||||
// the fund's payment id, so the two rows coexist under the same partial-unique index.
|
||||
//
|
||||
// The provider-side money movement must already have succeeded when this is called — the ledger must
|
||||
// never claim a refund that did not happen.
|
||||
func (s *Service) RefundOrderFullAs(ctx context.Context, orderID uuid.UUID, provider, providerRefundID string) (RefundOutcome, error) {
|
||||
o, err := s.store.orderByID(ctx, orderID)
|
||||
if err != nil {
|
||||
return RefundOutcome{}, err
|
||||
@@ -24,7 +37,7 @@ func (s *Service) RefundOrderFull(ctx context.Context, orderID uuid.UUID) (Refun
|
||||
if err != nil {
|
||||
return RefundOutcome{}, err
|
||||
}
|
||||
return s.store.refund(ctx, orderID, providerAdmin, orderID.String(), refunded, s.clock())
|
||||
return s.store.refund(ctx, orderID, provider, providerRefundID, refunded, s.clock())
|
||||
}
|
||||
|
||||
// LedgerExportRow is one append-only ledger row for the tax / reconciliation export, carrying the
|
||||
|
||||
@@ -64,6 +64,83 @@ func directShop(cxt Context) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// AttachProviderPayment records the provider's own payment identifier on a pending order, right
|
||||
// after the provider mints it and before the customer has paid. Two later paths depend on it: the
|
||||
// reconcile sweep, which asks the provider what became of an order no callback ever confirmed, and a
|
||||
// refund, which must address the original payment. It does not credit anything and does not change
|
||||
// the order status.
|
||||
func (s *Service) AttachProviderPayment(ctx context.Context, orderID uuid.UUID, provider, providerPaymentID string) error {
|
||||
return s.store.attachProviderPayment(ctx, orderID, provider, providerPaymentID, s.clock())
|
||||
}
|
||||
|
||||
// OrderRef identifies a stored order to a provider: which rail settles it, that rail's own payment
|
||||
// id, the merchant shop channel it was issued through, and the amount and account it belongs to. It
|
||||
// is what the reconcile sweep and the refund path need without giving them the whole order.
|
||||
type OrderRef struct {
|
||||
OrderID uuid.UUID
|
||||
AccountID uuid.UUID
|
||||
Provider string
|
||||
PaymentID string
|
||||
Shop string
|
||||
Amount Money
|
||||
Status string
|
||||
}
|
||||
|
||||
// orderRef projects a stored order onto an OrderRef.
|
||||
func orderRef(o orderRow) (OrderRef, error) {
|
||||
amount, err := MoneyFromMinor(o.expectedAmount, Currency(o.currency))
|
||||
if err != nil {
|
||||
return OrderRef{}, err
|
||||
}
|
||||
return OrderRef{
|
||||
OrderID: o.orderID,
|
||||
AccountID: o.accountID,
|
||||
Provider: o.provider,
|
||||
PaymentID: o.paymentID,
|
||||
Shop: o.shop,
|
||||
Amount: amount,
|
||||
Status: o.status,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// OrderProviderRef reads how an order reaches its provider — the rail, that rail's payment id and
|
||||
// the shop it was issued through. The refund path uses it to call the right merchant account.
|
||||
func (s *Service) OrderProviderRef(ctx context.Context, orderID uuid.UUID) (OrderRef, error) {
|
||||
o, err := s.store.orderByID(ctx, orderID)
|
||||
if err != nil {
|
||||
return OrderRef{}, err
|
||||
}
|
||||
return orderRef(o)
|
||||
}
|
||||
|
||||
// reconcileBatch bounds one reconcile sweep, so a backlog cannot turn a periodic tick into a long
|
||||
// run of provider calls.
|
||||
const reconcileBatch = 50
|
||||
|
||||
// PendingForReconcile returns the pending orders that have reached their expiry age while carrying a
|
||||
// provider payment id — the ones where the money may well have moved but no callback ever told us.
|
||||
// The caller asks the provider for each one's real outcome before ExpireOrders writes them off.
|
||||
// Orders that never reached a payment are not returned: there is nothing to ask about.
|
||||
func (s *Service) PendingForReconcile(ctx context.Context) ([]OrderRef, error) {
|
||||
ttl, err := s.store.orderTTL(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := s.store.pendingForReconcile(ctx, ttl, s.clock(), reconcileBatch)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]OrderRef, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
ref, err := orderRef(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, ref)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// OrderItem returns a pending order's human title and the amount it charges, in the order's own
|
||||
// currency — the details a provider's item-lookup phase needs (VK's get_item). It reads the order
|
||||
// and the pack title, honouring the pack even if it was later deactivated (mirrors Fund).
|
||||
|
||||
@@ -182,6 +182,9 @@ type orderRow struct {
|
||||
currency string
|
||||
origin string
|
||||
status string
|
||||
provider string
|
||||
paymentID string
|
||||
shop string
|
||||
}
|
||||
|
||||
// orderByID reads an order, or ErrOrderNotFound.
|
||||
@@ -198,6 +201,12 @@ func (s *Store) orderByID(ctx context.Context, orderID uuid.UUID) (orderRow, err
|
||||
if err != nil {
|
||||
return orderRow{}, fmt.Errorf("payments: load order %s: %w", orderID, err)
|
||||
}
|
||||
return newOrderRow(o), nil
|
||||
}
|
||||
|
||||
// newOrderRow projects a stored order onto the intake's view of it, flattening the nullable provider
|
||||
// columns to their empty strings.
|
||||
func newOrderRow(o model.Orders) orderRow {
|
||||
return orderRow{
|
||||
orderID: o.OrderID,
|
||||
accountID: o.AccountID,
|
||||
@@ -206,7 +215,60 @@ func (s *Store) orderByID(ctx context.Context, orderID uuid.UUID) (orderRow, err
|
||||
currency: o.Currency,
|
||||
origin: o.Origin,
|
||||
status: o.Status,
|
||||
}, nil
|
||||
provider: derefString(o.Provider),
|
||||
paymentID: derefString(o.ProviderPaymentID),
|
||||
shop: o.Shop,
|
||||
}
|
||||
}
|
||||
|
||||
// derefString reads a nullable text column as a plain string, treating NULL as empty.
|
||||
func derefString(p *string) string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
return *p
|
||||
}
|
||||
|
||||
// attachProviderPayment records the provider's own payment id on a pending order, as soon as the
|
||||
// provider mints it. It is what later lets an unattended order be re-checked against the provider
|
||||
// and a refund address the right payment; fund overwrites it with the same value when the callback
|
||||
// lands. It never changes the order status.
|
||||
func (s *Store) attachProviderPayment(ctx context.Context, orderID uuid.UUID, provider, providerPaymentID string, now time.Time) error {
|
||||
_, err := table.Orders.
|
||||
UPDATE(table.Orders.Provider, table.Orders.ProviderPaymentID, table.Orders.UpdatedAt).
|
||||
SET(postgres.String(provider), postgres.String(providerPaymentID), postgres.TimestampzT(now)).
|
||||
WHERE(table.Orders.OrderID.EQ(postgres.UUID(orderID))).
|
||||
ExecContext(ctx, s.db)
|
||||
if err != nil {
|
||||
return fmt.Errorf("payments: attach provider payment to order %s: %w", orderID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// pendingForReconcile reads the pending orders that have reached their expiry age and carry a
|
||||
// provider payment id — the ones whose real outcome is still unknown to us because no callback ever
|
||||
// arrived. The caller asks the provider what happened before the order is written off as expired.
|
||||
// Orders with no provider payment id are skipped: the customer never got as far as a payment.
|
||||
func (s *Store) pendingForReconcile(ctx context.Context, ttlSeconds int, now time.Time, limit int) ([]orderRow, error) {
|
||||
cutoff := now.Add(-time.Duration(ttlSeconds) * time.Second)
|
||||
var rows []model.Orders
|
||||
err := postgres.SELECT(table.Orders.AllColumns).
|
||||
FROM(table.Orders).
|
||||
WHERE(table.Orders.Status.EQ(postgres.String("pending")).
|
||||
AND(table.Orders.CreatedAt.LT(postgres.TimestampzT(cutoff))).
|
||||
AND(table.Orders.ProviderPaymentID.IS_NOT_NULL()).
|
||||
AND(table.Orders.ProviderPaymentID.NOT_EQ(postgres.String("")))).
|
||||
ORDER_BY(table.Orders.CreatedAt.ASC()).
|
||||
LIMIT(int64(limit)).
|
||||
QueryContext(ctx, s.db, &rows)
|
||||
if err != nil && !errors.Is(err, qrm.ErrNoRows) {
|
||||
return nil, fmt.Errorf("payments: load orders for reconcile: %w", err)
|
||||
}
|
||||
out := make([]orderRow, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, newOrderRow(r))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// FundOutcome reports the result of an intake credit: whose balance, which segment and how many
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
# Robokassa — the retired direct rail
|
||||
|
||||
Robokassa used to settle the **`direct`** (RUB) rail. It was replaced by
|
||||
[YooKassa](../yookassa) and is now **dormant**: the code, its tests and its wiring are all still
|
||||
here, but **no deployment sets its credentials**, so `Shops.Configured()` is false, the Result
|
||||
callback route is never registered, and the direct rail resolves to YooKassa.
|
||||
|
||||
The code is kept because the merchant relationship could come back. This file records the operational
|
||||
knowledge that used to live in `deploy/` and `.gitea/workflows/` and was removed from there so the
|
||||
live configuration stays free of dead variables.
|
||||
|
||||
## How the rail is chosen
|
||||
|
||||
`handleWalletOrder` (`backend/internal/server/handlers_intake.go`) resolves the direct rail in this
|
||||
order:
|
||||
|
||||
1. a configured YooKassa shop for the platform channel → YooKassa;
|
||||
2. otherwise a configured Robokassa shop → **this rail**;
|
||||
3. otherwise `501 rail_unavailable`.
|
||||
|
||||
So reviving Robokassa is a **credentials change, not a code change**: restore the environment block
|
||||
below and the rail comes back on the next deploy. If both providers are configured, YooKassa wins.
|
||||
|
||||
## Environment variables (retired)
|
||||
|
||||
The backend still reads all of these. They were removed from `deploy/docker-compose.yml`,
|
||||
`deploy/.env.example`, `deploy/write-prod-env.sh`, `deploy/README.md` and the three workflows.
|
||||
|
||||
| Backend variable (inside the container) | Meaning |
|
||||
| --- | --- |
|
||||
| `BACKEND_ROBOKASSA_MERCHANT_LOGIN` | Shop login. Empty ⇒ the shop is dropped. Legacy single-shop form: it seeds the **web** channel. |
|
||||
| `BACKEND_ROBOKASSA_PASSWORD1` | Pass phrase #1 — signs the outgoing payment request. |
|
||||
| `BACKEND_ROBOKASSA_PASSWORD2` | Pass phrase #2 — signs and so verifies the incoming Result callback. |
|
||||
| `BACKEND_ROBOKASSA_TEST` | `1` adds `IsTest=1`, so the shop simulates payments against its **test** pass phrases and no money moves. Empty/`0` is live. |
|
||||
| `BACKEND_ROBOKASSA_WEB_{MERCHANT_LOGIN,PASSWORD1,PASSWORD2,TEST}` | The per-channel **web** shop; overrides the legacy set above. |
|
||||
| `BACKEND_ROBOKASSA_ANDROID_{MERCHANT_LOGIN,PASSWORD1,PASSWORD2,TEST}` | The per-channel **android** (RuStore) shop (D42). |
|
||||
|
||||
Password3 (Robokassa's JWT-invoice API) was never used.
|
||||
|
||||
The three-step naming this repo uses was: a Gitea secret/variable `PROD_BACKEND_ROBOKASSA_PASSWORD1`
|
||||
→ mapped by the workflow to `ROBOKASSA_PASSWORD1` → mapped by compose to
|
||||
`BACKEND_ROBOKASSA_PASSWORD1` inside the container.
|
||||
|
||||
| Contour | Gitea entries that fed the rail |
|
||||
| --- | --- |
|
||||
| test | secrets `TEST_BACKEND_ROBOKASSA_{MERCHANT_LOGIN,PASSWORD1,PASSWORD2}`; `ROBOKASSA_TEST` was hard-coded to `"1"` in `ci.yaml` so the contour could never take real money whatever the shop's own mode. |
|
||||
| prod | secrets `PROD_BACKEND_ROBOKASSA_{MERCHANT_LOGIN,PASSWORD1,PASSWORD2}`; **variable** `PROD_BACKEND_ROBOKASSA_TEST`, deliberately a variable so go-live was a flag flip rather than a secret rotation. Mirrored in `prod-rollback.yaml` so a rollback did not dark the rail. |
|
||||
|
||||
**Known gap, if the rail is ever revived:** the per-channel `…_WEB_*` / `…_ANDROID_*` variables
|
||||
existed in compose and in `config.go` but were **never** carried by any workflow or by
|
||||
`write-prod-env.sh` — only the four legacy variables reached prod. A revival that wants the
|
||||
multi-shop split must add them to the deploy plumbing too.
|
||||
|
||||
## Cabinet configuration
|
||||
|
||||
Set in the Robokassa ЛКК, per shop:
|
||||
|
||||
- **Result URL** — `https://<host>/pay/robokassa/result/<channel>` (`…/web`, `…/android`). The bare
|
||||
`…/pay/robokassa/result` is the legacy path and is verified as the **web** shop. Method: POST.
|
||||
Each shop's callback is verified only by its own Password2 (`Shops.Verifier` does not fall back).
|
||||
- **Success URL** — `https://<host>/pay/robokassa/success`.
|
||||
- **Fail URL** — `https://<host>/pay/robokassa/fail`.
|
||||
- **Signature algorithm** — SHA-256 (the code hard-codes it; a shop set to MD5 will not verify).
|
||||
- **54-ФЗ** — fiscalization was cabinet-side through Robokassa's cloud cash register (kassa + ОФД +
|
||||
СНО configured by the owner), so no receipt data was ever sent from code. YooKassa does **not**
|
||||
work this way: it registers a receipt only if the request carries one.
|
||||
|
||||
The gateway routes (`gateway/internal/connectsrv/server.go`) are still registered, so the cabinet
|
||||
URLs above stay reachable; only the backend has no shop to verify against.
|
||||
|
||||
## Protocol notes
|
||||
|
||||
- The order id is a UUID and Robokassa's `InvId` is numeric, so the order rides the custom-parameter
|
||||
channel as **`Shp_order`** and `InvId` is always sent as `0`. Idempotency at the credit site is
|
||||
therefore keyed on the order id, not on a provider payment id.
|
||||
- Outgoing signature base: `MerchantLogin:OutSum:InvId:Password1[:Shp_key=value…sorted]`.
|
||||
- Incoming (Result) signature base: `OutSum:InvId:Password2[:Shp_key=value…sorted]`, compared
|
||||
case-insensitively.
|
||||
- The Result callback must be answered with the body `OK<InvId>`, which the gateway echoes verbatim.
|
||||
|
||||
## What is *not* reversible by configuration
|
||||
|
||||
Ledger rows written while this rail was live carry `provider = 'robokassa'`. That literal is
|
||||
load-bearing for the `(provider, provider_payment_id)` idempotency index and must never be renamed or
|
||||
reused for another provider.
|
||||
@@ -3,6 +3,12 @@
|
||||
// an order. 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.
|
||||
//
|
||||
// This rail is RETIRED: YooKassa settles the direct rail now, and no deployment sets these
|
||||
// credentials, so the shop set is empty and the rail is dormant. It stays wired as the fallback the
|
||||
// direct rail resolves to when YooKassa has no shop, which makes reviving it a credentials change
|
||||
// rather than a code change. README.md in this directory records the retired environment variables,
|
||||
// the cabinet configuration and how to bring the rail back.
|
||||
//
|
||||
// The order is threaded through Robokassa's custom-parameter channel as Shp_order=<order id> (echoed
|
||||
// back in the callback and bound into the signature), not the numeric InvId, because an order id is
|
||||
// a uuid; InvId is sent as 0. Idempotency is therefore keyed on the order id at the credit site.
|
||||
|
||||
@@ -79,16 +79,20 @@ func (s *Server) registerRoutes() {
|
||||
s.internal.GET("/offer/pricing", s.handleOfferPricing)
|
||||
}
|
||||
if s.payments != nil {
|
||||
// The money order endpoint dispatches by rail (direct → Robokassa, vk → VK); an
|
||||
// The money order endpoint dispatches by rail (direct → YooKassa, vk → VK); an
|
||||
// unsupported or unconfigured rail returns 501 from the handler. The provider callbacks are
|
||||
// gateway-only (the single writer): the VK payment callback (both phases handled here), and
|
||||
// the Robokassa Result callback when a merchant is configured.
|
||||
// gateway-only (the single writer): the VK payment callback (both phases handled here), the
|
||||
// YooKassa notification, and the retired Robokassa Result callback if a merchant is ever
|
||||
// configured again.
|
||||
u.POST("/wallet/order", s.handleWalletOrder)
|
||||
s.internal.POST("/payments/vk/callback", s.handleVKCallback)
|
||||
// The Telegram Stars rail: the bot forwards a pre_checkout validation and a completed
|
||||
// payment over the reverse bot-link; the gateway proxies both onto these gateway-only routes.
|
||||
s.internal.POST("/payments/telegram/precheckout", s.handleTelegramPreCheckout)
|
||||
s.internal.POST("/payments/telegram/payment", s.handleTelegramPayment)
|
||||
if s.yookassa.Configured() {
|
||||
s.internal.POST("/payments/yookassa/notify", s.handleYooKassaNotify)
|
||||
}
|
||||
if s.robokassa.Configured() {
|
||||
s.internal.POST("/payments/robokassa/result", s.handleRobokassaResult)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"strconv"
|
||||
@@ -9,11 +10,17 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"scrabble/backend/internal/payments"
|
||||
)
|
||||
|
||||
// consoleRefund refunds a paid order in full at the operator's request. The operator performs the
|
||||
// actual money refund on the rail (Robokassa cabinet / VK support / Telegram refundStarPayment);
|
||||
// this records it — a refund ledger row and a floor-0 chip revoke (never negative, D27). Idempotent.
|
||||
// consoleRefund refunds a paid order in full at the operator's request: it records the reversal — a
|
||||
// refund ledger row and a floor-0 chip revoke (never negative, D27) — and, on the direct rail, moves
|
||||
// the money back through the provider's refund API first, so one click does the whole job and the
|
||||
// ledger cannot claim a refund the provider never made. The rails with no refund API of ours (VK
|
||||
// support, Telegram refundStarPayment, the Robokassa cabinet) still need the operator to move the
|
||||
// money by hand; this records that. Idempotent either way.
|
||||
func (s *Server) consoleRefund(c *gin.Context) {
|
||||
id, ok := s.consoleUUID(c, "/_gm/users")
|
||||
if !ok {
|
||||
@@ -29,7 +36,13 @@ func (s *Server) consoleRefund(c *gin.Context) {
|
||||
s.renderConsoleMessage(c, "Refund failed", "no order to refund", back)
|
||||
return
|
||||
}
|
||||
out, err := s.payments.RefundOrderFull(c.Request.Context(), orderID)
|
||||
ctx := c.Request.Context()
|
||||
ref, err := s.payments.OrderProviderRef(ctx, orderID)
|
||||
if err != nil {
|
||||
s.renderConsoleMessage(c, "Refund failed", err.Error(), back)
|
||||
return
|
||||
}
|
||||
out, err := s.refundOrder(ctx, ref)
|
||||
if err != nil {
|
||||
s.renderConsoleMessage(c, "Refund failed", err.Error(), back)
|
||||
return
|
||||
@@ -46,6 +59,25 @@ func (s *Server) consoleRefund(c *gin.Context) {
|
||||
s.renderConsoleMessage(c, "Refunded", msg, back)
|
||||
}
|
||||
|
||||
// refundOrder reverses a paid order, moving the money back through the rail's own refund API when
|
||||
// there is one. The provider call comes first and a failure aborts with nothing recorded: the ledger
|
||||
// must never claim a refund that did not happen. The reversal is then recorded under the provider's
|
||||
// own refund id, which keeps the ledger reconcilable against the provider's records.
|
||||
//
|
||||
// A rail with no refund API of ours records under the operator's key instead, and the operator moves
|
||||
// the money by hand (VK support, Telegram refundStarPayment, the Robokassa cabinet).
|
||||
func (s *Server) refundOrder(ctx context.Context, ref payments.OrderRef) (payments.RefundOutcome, error) {
|
||||
if ref.Provider != providerYooKassa {
|
||||
return s.payments.RefundOrderFull(ctx, ref.OrderID)
|
||||
}
|
||||
refundID, err := s.refundYooKassa(ctx, ref)
|
||||
if err != nil {
|
||||
s.log.Error("yookassa refund failed", zap.String("order", ref.OrderID.String()), zap.Error(err))
|
||||
return payments.RefundOutcome{}, fmt.Errorf("the provider did not refund the payment, nothing was recorded: %w", err)
|
||||
}
|
||||
return s.payments.RefundOrderFullAs(ctx, ref.OrderID, providerYooKassa, refundID)
|
||||
}
|
||||
|
||||
// consoleLedgerExport streams the entire append-only ledger as a CSV attachment for tax reporting
|
||||
// and rail reconciliation. The snapshot column carries the raw purchase/refund JSON.
|
||||
func (s *Server) consoleLedgerExport(c *gin.Context) {
|
||||
|
||||
@@ -15,20 +15,28 @@ import (
|
||||
"scrabble/backend/internal/robokassa"
|
||||
)
|
||||
|
||||
// Ledger/order provider tags per rail.
|
||||
// Ledger/order provider tags per rail. providerRobokassa is retired but never renamed or reused:
|
||||
// historical ledger rows carry it, and it is load-bearing for the (provider, provider_payment_id)
|
||||
// idempotency index.
|
||||
const (
|
||||
providerRobokassa = "robokassa" // the direct (RUB) rail
|
||||
providerYooKassa = "yookassa" // the direct (RUB) rail
|
||||
providerRobokassa = "robokassa" // the retired direct (RUB) rail
|
||||
providerVK = "vk" // the VK Votes rail
|
||||
providerTelegram = "telegram" // the Telegram Stars (XTR) rail
|
||||
)
|
||||
|
||||
// yookassaReturnPath is where YooKassa returns the customer's browser after the hosted payment page.
|
||||
// The credit never rides this redirect — it rides the server notification — so the page only closes
|
||||
// the payment window and drops the customer back into the live app.
|
||||
const yookassaReturnPath = "/pay/yookassa/return"
|
||||
|
||||
// walletOrderRequest is the POST body of a chip-pack purchase: the pack to fund.
|
||||
type walletOrderRequest struct {
|
||||
ProductID string `json:"product_id"`
|
||||
}
|
||||
|
||||
// walletOrderResponse returns the created order id and the rail's launch details. RedirectURL is
|
||||
// the provider's hosted-payment URL for the direct rail (empty for VK/Telegram, which settle
|
||||
// the provider's hosted-payment page for the direct rail (empty for VK/Telegram, which settle
|
||||
// in-app). Rail names the settling rail so the gateway knows how to launch it; for the Telegram
|
||||
// Stars rail the gateway mints the invoice link from InvoiceTitle and InvoiceAmount (whole stars)
|
||||
// via the bot and returns it in RedirectURL.
|
||||
@@ -41,7 +49,7 @@ type walletOrderResponse struct {
|
||||
}
|
||||
|
||||
// handleWalletOrder opens a pending order to fund a chip pack and returns the rail's launch
|
||||
// details for the client: the Robokassa hosted-payment URL (direct), the order id for
|
||||
// details for the client: the provider's hosted-payment URL (direct), the order id for
|
||||
// VKWebAppShowOrderBox (VK), or the pack title and star amount the gateway mints into a Stars
|
||||
// invoice link (Telegram). It enforces the wallet gate and, on the direct rail, D36 (a purchase
|
||||
// requires a confirmed email anchor). No chips are credited here — only later, by the verified
|
||||
@@ -84,13 +92,20 @@ func (s *Server) handleWalletOrder(c *gin.Context) {
|
||||
}
|
||||
switch cxt.Kind {
|
||||
case payments.SourceDirect:
|
||||
shop, ok := s.robokassa.Shop(cxt.Subtype)
|
||||
if !ok {
|
||||
// YooKassa settles the direct rail. Robokassa is retired and normally unconfigured, so it is
|
||||
// only reached when YooKassa has no shop for this channel — which is what makes reviving the
|
||||
// old rail a credentials change rather than a code change. The rail is resolved before any
|
||||
// account-level gate, so an unconfigured rail still reads as unavailable rather than as
|
||||
// something the customer could fix.
|
||||
ykShop, onYooKassa := s.yookassa.Shop(cxt.Subtype)
|
||||
rkShop, onRobokassa := s.robokassa.Shop(cxt.Subtype)
|
||||
if !onYooKassa && !onRobokassa {
|
||||
c.AbortWithStatusJSON(http.StatusNotImplemented, errorResponse{Error: errorBody{Code: "rail_unavailable", Message: "this payment method is not available"}})
|
||||
return
|
||||
}
|
||||
// D36: a direct purchase requires a confirmed email anchor.
|
||||
hasEmail, err := s.accounts.HasConfirmedEmail(ctx, uid)
|
||||
// D36: a direct purchase requires a confirmed email anchor, which is also where the fiscal
|
||||
// receipt is delivered.
|
||||
email, hasEmail, err := s.accounts.ConfirmedEmail(ctx, uid)
|
||||
if err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
@@ -99,6 +114,10 @@ func (s *Server) handleWalletOrder(c *gin.Context) {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, errorResponse{Error: errorBody{Code: "email_required", Message: "confirm your email before making a purchase"}})
|
||||
return
|
||||
}
|
||||
if onYooKassa {
|
||||
s.orderYooKassa(c, uid, cxt, present, productID, ykShop, email)
|
||||
return
|
||||
}
|
||||
res, err := s.payments.CreateOrder(ctx, uid, cxt, present, productID, providerRobokassa)
|
||||
if err != nil {
|
||||
s.abortErr(c, err)
|
||||
@@ -106,7 +125,7 @@ func (s *Server) handleWalletOrder(c *gin.Context) {
|
||||
}
|
||||
c.JSON(http.StatusOK, walletOrderResponse{
|
||||
OrderID: res.OrderID.String(),
|
||||
RedirectURL: shop.PaymentURL(res.OrderID, res.Amount.Major(), res.Title),
|
||||
RedirectURL: rkShop.PaymentURL(res.OrderID, res.Amount.Major(), res.Title),
|
||||
Rail: providerRobokassa,
|
||||
})
|
||||
case payments.SourceVK:
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"scrabble/backend/internal/payments"
|
||||
"scrabble/backend/internal/yookassa"
|
||||
)
|
||||
|
||||
// maxNotifyBytes caps the notification body the backend will read. The gateway already forwards only
|
||||
// what it read from the provider; this is the backend's own ceiling on an untrusted body.
|
||||
const maxNotifyBytes = 1 << 20
|
||||
|
||||
// orderYooKassa opens a pending order on the direct rail and mints the YooKassa payment the customer
|
||||
// is redirected to. shop is the merchant shop the caller resolved for this platform channel, and
|
||||
// email is the account's confirmed address (D36), which doubles as the delivery address of the fiscal
|
||||
// receipt this rail must send with every payment. No chips are credited here —
|
||||
// only later, by the verified notification (or the reconcile sweep).
|
||||
func (s *Server) orderYooKassa(c *gin.Context, uid uuid.UUID, cxt payments.Context, present []payments.Source, productID uuid.UUID, shop yookassa.Config, email string) {
|
||||
ctx := c.Request.Context()
|
||||
res, err := s.payments.CreateOrder(ctx, uid, cxt, present, productID, providerYooKassa)
|
||||
if err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
amount := yookassa.Amount{Value: res.Amount.Major(), Currency: string(res.Amount.Currency())}
|
||||
payment, err := shop.CreatePayment(ctx, yookassa.PaymentRequest{
|
||||
Amount: amount,
|
||||
Capture: true,
|
||||
Confirmation: yookassa.Confirmation{
|
||||
Type: yookassa.ConfirmationRedirect,
|
||||
ReturnURL: strings.TrimSuffix(s.publicURL, "/") + yookassaReturnPath,
|
||||
},
|
||||
Description: yookassa.TruncateDescription(res.Title),
|
||||
Metadata: map[string]string{yookassa.MetadataOrderID: res.OrderID.String()},
|
||||
// «Чеки от ЮKassa»: the receipt is registered only if we send it, so a malformed one is an
|
||||
// API error here rather than a silently missing fiscal document.
|
||||
Receipt: yookassa.SingleItemReceipt(email, res.Title, amount, s.vatCode),
|
||||
}, res.OrderID.String())
|
||||
if err != nil {
|
||||
// The order stays pending and unpaid; it expires on its own. The customer sees the generic
|
||||
// error, and the log carries the provider's own description (which names the offending
|
||||
// parameter when a receipt is at fault).
|
||||
s.log.Error("yookassa create payment failed", zap.String("order", res.OrderID.String()), zap.Error(err))
|
||||
c.AbortWithStatusJSON(http.StatusBadGateway, errorResponse{Error: errorBody{Code: "rail_unavailable", Message: "this payment method is not available right now"}})
|
||||
return
|
||||
}
|
||||
if payment.Confirmation.ConfirmationURL == "" {
|
||||
s.log.Error("yookassa payment has no confirmation url",
|
||||
zap.String("order", res.OrderID.String()), zap.String("payment", payment.ID))
|
||||
c.AbortWithStatusJSON(http.StatusBadGateway, errorResponse{Error: errorBody{Code: "rail_unavailable", Message: "this payment method is not available right now"}})
|
||||
return
|
||||
}
|
||||
// Record the provider's payment id so the reconcile sweep and any refund can address it. A
|
||||
// failure here is logged and tolerated: the credit path matches on the order id carried in the
|
||||
// payment metadata, so the purchase still completes — only the safety net and refunds lose their
|
||||
// handle on this one order.
|
||||
if err := s.payments.AttachProviderPayment(ctx, res.OrderID, providerYooKassa, payment.ID); err != nil {
|
||||
s.log.Error("yookassa attach payment id failed",
|
||||
zap.String("order", res.OrderID.String()), zap.String("payment", payment.ID), zap.Error(err))
|
||||
}
|
||||
c.JSON(http.StatusOK, walletOrderResponse{
|
||||
OrderID: res.OrderID.String(),
|
||||
RedirectURL: payment.Confirmation.ConfirmationURL,
|
||||
Rail: providerYooKassa,
|
||||
})
|
||||
}
|
||||
|
||||
// handleYooKassaNotify is the YooKassa webhook, reached only through the gateway (which checks the
|
||||
// sender address and forwards the raw JSON body on the internal, gateway-only route).
|
||||
//
|
||||
// YooKassa does not sign notifications, so the body is never evidence: it only names a payment to
|
||||
// re-read. Everything acted on comes from the confirming GetPayment. A succeeded payment credits its
|
||||
// order exactly once (idempotent, and honoured even if the order expired); a canceled one records a
|
||||
// failed event so the customer is told the attempt did not go through.
|
||||
//
|
||||
// The reply tells the provider whether to redeliver: 200 for anything durably decided — including a
|
||||
// duplicate and a permanent rejection, which no retry could fix — and 5xx only for a transient
|
||||
// failure we want redelivered (YooKassa retries for 24 hours). An unrecognised sender is a 403,
|
||||
// which is also redelivered: if YooKassa ever changed its published ranges, the retries plus the
|
||||
// reconcile sweep would keep the money owed while the allowlist is corrected.
|
||||
func (s *Server) handleYooKassaNotify(c *gin.Context) {
|
||||
// The sender check runs before anything else. It is defence in depth — the confirming GetPayment
|
||||
// is what actually establishes authenticity — but it is what stops a forger from turning each
|
||||
// fabricated notification into an outbound API call of ours. The address is the true sender,
|
||||
// forwarded by the gateway, which is the only hop that sees it.
|
||||
if ip := clientIP(c); !yookassa.AllowedSender(ip) {
|
||||
s.log.Warn("yookassa notify: sender is not a YooKassa address", zap.String("ip", ip))
|
||||
c.String(http.StatusForbidden, "forbidden")
|
||||
return
|
||||
}
|
||||
raw, err := io.ReadAll(io.LimitReader(c.Request.Body, maxNotifyBytes))
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, "error")
|
||||
return
|
||||
}
|
||||
note, err := yookassa.ParseNotification(raw)
|
||||
if err != nil {
|
||||
s.log.Warn("yookassa notify: malformed body", zap.Error(err))
|
||||
c.String(http.StatusOK, "ignored")
|
||||
return
|
||||
}
|
||||
switch note.Event {
|
||||
case yookassa.EventPaymentSucceeded, yookassa.EventPaymentCanceled:
|
||||
default:
|
||||
// Refund events are informational: refunds are issued through the API, which records them
|
||||
// synchronously. Anything else is a subscription we do not act on.
|
||||
c.String(http.StatusOK, "ignored")
|
||||
return
|
||||
}
|
||||
claimed, err := note.Payment()
|
||||
if err != nil {
|
||||
s.log.Warn("yookassa notify: unusable payment object", zap.String("event", note.Event), zap.Error(err))
|
||||
c.String(http.StatusOK, "ignored")
|
||||
return
|
||||
}
|
||||
orderID, err := uuid.Parse(claimed.OrderID())
|
||||
if err != nil {
|
||||
s.log.Warn("yookassa notify: no order in payment metadata", zap.String("payment", claimed.ID))
|
||||
c.String(http.StatusOK, "ignored")
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
ref, err := s.payments.OrderProviderRef(ctx, orderID)
|
||||
if errors.Is(err, payments.ErrOrderNotFound) {
|
||||
s.log.Warn("yookassa notify: unknown order", zap.String("order", orderID.String()), zap.String("payment", claimed.ID))
|
||||
c.String(http.StatusOK, "ignored")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
s.log.Error("yookassa notify: order lookup failed", zap.String("order", orderID.String()), zap.Error(err))
|
||||
c.String(http.StatusInternalServerError, "error")
|
||||
return
|
||||
}
|
||||
|
||||
payment, shop, err := s.confirmYooKassaPayment(ctx, claimed.ID, claimed.Recipient.AccountID, ref)
|
||||
if err != nil {
|
||||
var apiErr *yookassa.APIError
|
||||
if errors.As(err, &apiErr) && !apiErr.Retryable() {
|
||||
// The provider says this payment is not ours or not readable — a forged or stale
|
||||
// notification. Nothing to redeliver.
|
||||
s.log.Warn("yookassa notify: payment not confirmed",
|
||||
zap.String("order", orderID.String()), zap.String("payment", claimed.ID), zap.Error(err))
|
||||
c.String(http.StatusOK, "ignored")
|
||||
return
|
||||
}
|
||||
s.log.Error("yookassa notify: confirm failed",
|
||||
zap.String("order", orderID.String()), zap.String("payment", claimed.ID), zap.Error(err))
|
||||
c.String(http.StatusInternalServerError, "error")
|
||||
return
|
||||
}
|
||||
if !s.yooKassaPaymentMatchesOrder(payment, shop, orderID) {
|
||||
c.String(http.StatusOK, "ignored")
|
||||
return
|
||||
}
|
||||
|
||||
switch payment.Status {
|
||||
case yookassa.StatusSucceeded:
|
||||
if err := s.creditYooKassaPayment(ctx, orderID, payment); err != nil {
|
||||
if isPermanentFundError(err) {
|
||||
s.log.Warn("yookassa notify rejected", zap.String("order", orderID.String()), zap.Error(err))
|
||||
c.String(http.StatusOK, "rejected")
|
||||
return
|
||||
}
|
||||
s.log.Error("yookassa fund failed", zap.String("order", orderID.String()), zap.Error(err))
|
||||
c.String(http.StatusInternalServerError, "error")
|
||||
return
|
||||
}
|
||||
case yookassa.StatusCanceled:
|
||||
// An active decline (§9): the customer is told the attempt failed. An order abandoned without
|
||||
// a decline never reaches here — it just expires, invisibly.
|
||||
s.recordYooKassaFailure(ctx, ref.AccountID, orderID, payment)
|
||||
default:
|
||||
// Still pending: the notification raced ahead of the payment, or was forged. Either way the
|
||||
// reconcile sweep will settle the order, so there is nothing to redeliver.
|
||||
s.log.Info("yookassa notify: payment not final",
|
||||
zap.String("order", orderID.String()), zap.String("status", payment.Status))
|
||||
}
|
||||
c.String(http.StatusOK, "ok")
|
||||
}
|
||||
|
||||
// confirmYooKassaPayment re-reads a payment from the API — the authenticity check for the whole rail,
|
||||
// since notifications are unsigned. The shop is chosen by the shop id the payment claims to have been
|
||||
// paid to, falling back to the merchant channel recorded on the order; a claim naming a shop we do
|
||||
// not run is not honoured with our own credentials.
|
||||
func (s *Server) confirmYooKassaPayment(ctx context.Context, paymentID, claimedShopID string, ref payments.OrderRef) (yookassa.Payment, yookassa.Config, error) {
|
||||
_, shop, ok := s.yookassa.ByShopID(claimedShopID)
|
||||
if !ok {
|
||||
shop, ok = s.yookassa.Shop(ref.Shop)
|
||||
}
|
||||
if !ok {
|
||||
return yookassa.Payment{}, yookassa.Config{}, yookassa.ErrUnconfigured
|
||||
}
|
||||
payment, err := shop.GetPayment(ctx, paymentID)
|
||||
return payment, shop, err
|
||||
}
|
||||
|
||||
// yooKassaPaymentMatchesOrder checks the confirmed payment really belongs to the order being
|
||||
// credited and to the kind of shop we think it is. It rejects a payment whose metadata names a
|
||||
// different order, and — the guard that keeps play money out of the live ledger — a live payment
|
||||
// arriving on test credentials or a test payment on live ones.
|
||||
func (s *Server) yooKassaPaymentMatchesOrder(payment yookassa.Payment, shop yookassa.Config, orderID uuid.UUID) bool {
|
||||
if payment.OrderID() != orderID.String() {
|
||||
s.log.Warn("yookassa: confirmed payment belongs to another order",
|
||||
zap.String("order", orderID.String()), zap.String("payment", payment.ID),
|
||||
zap.String("payment_order", payment.OrderID()))
|
||||
return false
|
||||
}
|
||||
if payment.Test != shop.IsTest {
|
||||
s.log.Error("yookassa: payment test flag does not match the shop; refusing to credit",
|
||||
zap.String("order", orderID.String()), zap.String("payment", payment.ID),
|
||||
zap.Bool("payment_test", payment.Test), zap.Bool("shop_test", shop.IsTest))
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// creditYooKassaPayment credits a confirmed succeeded payment to its order exactly once and records
|
||||
// the succeeded event a duplicate must not re-emit.
|
||||
func (s *Server) creditYooKassaPayment(ctx context.Context, orderID uuid.UUID, payment yookassa.Payment) error {
|
||||
paid, err := payments.ParseMoney(payment.Amount.Value, payments.Currency(payment.Amount.Currency))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
outcome, err := s.payments.Fund(ctx, orderID, providerYooKassa, payment.ID, paid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if outcome.AlreadyCredited {
|
||||
return nil
|
||||
}
|
||||
payload, _ := json.Marshal(map[string]any{"chips": outcome.Chips, "source": string(outcome.Source)})
|
||||
if err := s.payments.RecordPaymentEvent(ctx, outcome.AccountID, &orderID, "succeeded", payload); err != nil {
|
||||
// The credit is already committed; only the in-app notification is lost, and the wallet still
|
||||
// refreshes on the client's return.
|
||||
s.log.Error("record payment event failed", zap.String("order", orderID.String()), zap.Error(err))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// recordYooKassaFailure records the failed payment event for an actively declined payment, so the
|
||||
// customer learns the attempt did not go through instead of watching a balance that never moves.
|
||||
func (s *Server) recordYooKassaFailure(ctx context.Context, accountID uuid.UUID, orderID uuid.UUID, payment yookassa.Payment) {
|
||||
reason := ""
|
||||
if payment.CancellationDetails != nil {
|
||||
reason = payment.CancellationDetails.Reason
|
||||
}
|
||||
s.log.Info("yookassa payment canceled",
|
||||
zap.String("order", orderID.String()), zap.String("payment", payment.ID), zap.String("reason", reason))
|
||||
payload, _ := json.Marshal(map[string]any{"reason": reason})
|
||||
if err := s.payments.RecordPaymentEvent(ctx, accountID, &orderID, "failed", payload); err != nil {
|
||||
s.log.Error("record payment event failed", zap.String("order", orderID.String()), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// isPermanentFundError reports whether a credit failed for a reason no retry can fix — an order that
|
||||
// does not exist, an amount that does not match, or a product that is no longer a chip pack.
|
||||
func isPermanentFundError(err error) bool {
|
||||
return errors.Is(err, payments.ErrOrderNotFound) || errors.Is(err, payments.ErrAmountMismatch) ||
|
||||
errors.Is(err, payments.ErrNotAPack) || errors.Is(err, payments.ErrProductNotFound)
|
||||
}
|
||||
|
||||
// refundYooKassa returns the money for a paid order through the YooKassa refund API and reports the
|
||||
// provider's own refund id, which the ledger then records. The refund carries a receipt because the
|
||||
// rail registers a refund receipt the same way it registers a payment one, and the order id as the
|
||||
// idempotency key, so a repeated attempt returns the original refund instead of paying twice.
|
||||
//
|
||||
// It is called before anything is recorded: if the money does not move, nothing is written, and the
|
||||
// ledger never claims a refund that did not happen.
|
||||
func (s *Server) refundYooKassa(ctx context.Context, ref payments.OrderRef) (string, error) {
|
||||
shop, ok := s.yookassa.Shop(ref.Shop)
|
||||
if !ok {
|
||||
return "", yookassa.ErrUnconfigured
|
||||
}
|
||||
if ref.PaymentID == "" {
|
||||
return "", errors.New("the order carries no provider payment id to refund")
|
||||
}
|
||||
title, _, err := s.payments.OrderItem(ctx, ref.OrderID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
email, _, err := s.accounts.ConfirmedEmail(ctx, ref.AccountID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
amount := yookassa.Amount{Value: ref.Amount.Major(), Currency: string(ref.Amount.Currency())}
|
||||
refund, err := shop.CreateRefund(ctx, yookassa.RefundRequest{
|
||||
PaymentID: ref.PaymentID,
|
||||
Amount: amount,
|
||||
Receipt: yookassa.SingleItemReceipt(email, title, amount, s.vatCode),
|
||||
}, ref.OrderID.String())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if refund.ID == "" {
|
||||
return "", errors.New("the provider returned a refund with no id")
|
||||
}
|
||||
return refund.ID, nil
|
||||
}
|
||||
|
||||
// ReconcileYooKassaOrders asks YooKassa what became of every pending order that reached its expiry
|
||||
// age while carrying a payment id, and credits the ones that were in fact paid. It is the safety net
|
||||
// for a notification that was lost for good: YooKassa redelivers for 24 hours, so a short outage
|
||||
// heals itself, but a notification that never arrived at all would otherwise leave the money taken
|
||||
// and the chips unowed. It runs once per order, just before the order is written off as expired.
|
||||
//
|
||||
// It returns how many orders it credited. Failures are logged and skipped: the next sweep retries.
|
||||
func (s *Server) ReconcileYooKassaOrders(ctx context.Context) int {
|
||||
if !s.yookassa.Configured() || s.payments == nil {
|
||||
return 0
|
||||
}
|
||||
refs, err := s.payments.PendingForReconcile(ctx)
|
||||
if err != nil {
|
||||
s.log.Warn("yookassa reconcile: read pending orders failed", zap.Error(err))
|
||||
return 0
|
||||
}
|
||||
credited := 0
|
||||
for _, ref := range refs {
|
||||
if ref.Provider != providerYooKassa || ref.PaymentID == "" {
|
||||
continue
|
||||
}
|
||||
shop, ok := s.yookassa.Shop(ref.Shop)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
payment, err := shop.GetPayment(ctx, ref.PaymentID)
|
||||
if err != nil {
|
||||
s.log.Warn("yookassa reconcile: payment lookup failed",
|
||||
zap.String("order", ref.OrderID.String()), zap.String("payment", ref.PaymentID), zap.Error(err))
|
||||
continue
|
||||
}
|
||||
if payment.Status != yookassa.StatusSucceeded || !s.yooKassaPaymentMatchesOrder(payment, shop, ref.OrderID) {
|
||||
continue
|
||||
}
|
||||
if err := s.creditYooKassaPayment(ctx, ref.OrderID, payment); err != nil {
|
||||
s.log.Error("yookassa reconcile: credit failed",
|
||||
zap.String("order", ref.OrderID.String()), zap.Error(err))
|
||||
continue
|
||||
}
|
||||
s.log.Info("yookassa reconcile: credited an order no notification confirmed",
|
||||
zap.String("order", ref.OrderID.String()), zap.String("payment", payment.ID))
|
||||
credited++
|
||||
}
|
||||
return credited
|
||||
}
|
||||
@@ -36,6 +36,7 @@ import (
|
||||
"scrabble/backend/internal/session"
|
||||
"scrabble/backend/internal/social"
|
||||
"scrabble/backend/internal/telemetry"
|
||||
"scrabble/backend/internal/yookassa"
|
||||
)
|
||||
|
||||
// shutdownTimeout bounds how long Run waits for in-flight requests to finish
|
||||
@@ -115,8 +116,18 @@ type Deps struct {
|
||||
// Renderer is the image-render sidecar client for the PNG export artifact. A
|
||||
// nil Renderer makes the PNG download answer 404 (the GCG artifact still works).
|
||||
Renderer *render.Client
|
||||
// Robokassa configures the direct-rail (RUB) provider — one merchant shop per channel; an empty
|
||||
// set leaves the order and Result-callback endpoints unregistered.
|
||||
// YooKassa configures the direct-rail (RUB) provider — one merchant shop per channel; an empty
|
||||
// set leaves the order and notification endpoints unregistered and falls the direct rail back to
|
||||
// Robokassa.
|
||||
YooKassa yookassa.Shops
|
||||
// YooKassaVatCode is the VAT rate code stamped on every fiscal receipt line (54-ФЗ tag 1199).
|
||||
YooKassaVatCode int
|
||||
// PublicBaseURL is the canonical https origin the payment return URL is built from. It is
|
||||
// required whenever YooKassa is configured.
|
||||
PublicBaseURL string
|
||||
// Robokassa configures the retired direct-rail provider — one merchant shop per channel. No
|
||||
// deployment sets it, so the set is empty and the direct rail resolves to YooKassa; it stays
|
||||
// wired so restoring the rail is a credentials change (backend/internal/robokassa/README.md).
|
||||
Robokassa robokassa.Shops
|
||||
}
|
||||
|
||||
@@ -146,6 +157,9 @@ type Server struct {
|
||||
ads *ads.Service
|
||||
payments *payments.Service
|
||||
gamelimits *gamelimits.Service
|
||||
yookassa yookassa.Shops
|
||||
vatCode int
|
||||
publicURL string
|
||||
robokassa robokassa.Shops
|
||||
notifier notify.Publisher
|
||||
console *adminconsole.Renderer
|
||||
@@ -201,6 +215,9 @@ func New(addr string, deps Deps) *Server {
|
||||
ads: deps.Ads,
|
||||
payments: deps.Payments,
|
||||
gamelimits: deps.GameLimits,
|
||||
yookassa: deps.YooKassa,
|
||||
vatCode: deps.YooKassaVatCode,
|
||||
publicURL: deps.PublicBaseURL,
|
||||
robokassa: deps.Robokassa,
|
||||
notifier: notifier,
|
||||
renderer: deps.Renderer,
|
||||
|
||||
@@ -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