feat(payments): reverse refunds issued outside the console
CI / changes (pull_request) Successful in 11s
CI / unit (pull_request) Successful in 22s
CI / integration (pull_request) Successful in 29s
CI / ui (pull_request) Successful in 1m27s
CI / conformance (pull_request) Successful in 19s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m52s

Two holes on the refund path, both found by asking what happens when a refund
does not come from our own `/_gm` button.

The merchant cabinet is a second entry point. An operator can refund there, and
such a refund never passes through our API — so the money went back while the
chips stayed credited, silently. Handle `refund.succeeded`: the refund is
re-read from the API (the notification body is no more evidence here than it is
for a payment), bound back to its order through the payment id recorded when
the payment was minted, and reversed through the same engine. It is idempotent
on (provider, refund id), so the event for a refund the console already
recorded reverses nothing twice.

The reversal engine is full-refund-only by design — it revokes exactly what the
pack funded and rejects any other amount — so a partial refund is recorded as
nothing at all and logged loudly for an operator. There is no non-arbitrary way
to decide how many chips a part-refund costs, and guessing would be worse than
asking a human.

Second hole: a refund can still be canceled while pending, and the ledger is
append-only. Recording on any non-empty refund id therefore risked revoking a
customer's chips for money that stayed with us, with no way to take the row
back. The console now records only a `succeeded` refund and tells the operator
to press again otherwise — the idempotency key returns the same refund rather
than paying twice.

Tests: unit (GetRefund, the refund notification envelope, a non-final status
surfaced to the caller); integration (a cabinet refund is reversed once and a
redelivery is a no-op, the event after a console refund changes nothing, a
partial refund records nothing, an unconfirmed refund reverses nothing, a
pending refund records nothing until it settles and then does).

The suite shares one database and the ledger dedupes refunds globally, so the
fake provider now mints a refund id per payment — a constant id made one test's
refund look like another's duplicate.

Decisions D50 (amended) and D52; the notification subscription list in the
deploy docs gains refund.succeeded.
This commit is contained in:
Ilia Denisov
2026-07-28 09:18:19 +02:00
parent 92ba527575
commit 395a307eca
17 changed files with 529 additions and 34 deletions
@@ -37,8 +37,12 @@ type fakeYooKassa struct {
payments map[string]yookassa.Payment
// refundFails makes POST /refunds answer 500, standing in for a provider that did not move money.
refundFails bool
// refundStatus is the status a created refund reports; empty means succeeded.
refundStatus string
// refundCalls counts the refunds actually requested.
refundCalls int
// refunds answers GET /refunds/{id}; a missing id is a 404.
refunds map[string]yookassa.Refund
// createdIdempotenceKey records the key sent on the last create-payment call.
createdIdempotenceKey string
// lastReceipt records the receipt object of the last create-payment call.
@@ -48,7 +52,7 @@ type fakeYooKassa struct {
// 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{}}
f := &fakeYooKassa{payments: map[string]yookassa.Payment{}, refunds: map[string]yookassa.Refund{}}
srv := httptest.NewServer(http.HandlerFunc(f.serve))
t.Cleanup(srv.Close)
return f, yookassa.Config{ShopID: ykShopID, SecretKey: ykSecretKey, IsTest: true, BaseURL: srv.URL}
@@ -94,14 +98,37 @@ func (f *fakeYooKassa) serve(w http.ResponseWriter, r *http.Request) {
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,
})
status := f.refundStatus
if status == "" {
status = yookassa.StatusSucceeded
}
// The ledger dedupes refunds on (provider, refund id) across the whole database, so the id has
// to be unique per payment or one test's refund looks like another's duplicate.
refund := yookassa.Refund{ID: "refund-" + body.PaymentID, Status: status, PaymentID: body.PaymentID, Amount: body.Amount}
f.refunds[refund.ID] = refund
_ = json.NewEncoder(w).Encode(refund)
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/refunds/"):
refund, ok := f.refunds[strings.TrimPrefix(r.URL.Path, "/refunds/")]
if !ok {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"type":"error","description":"not found"}`))
return
}
_ = json.NewEncoder(w).Encode(refund)
default:
w.WriteHeader(http.StatusNotFound)
}
}
// setRefund publishes a refund the API will report — how a test stands up a refund that was issued
// somewhere other than through our own API, such as in the merchant cabinet.
func (f *fakeYooKassa) setRefund(id string, refund yookassa.Refund) {
f.mu.Lock()
defer f.mu.Unlock()
refund.ID = id
f.refunds[id] = refund
}
// 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)) {
@@ -383,8 +410,8 @@ func TestYooKassaConsoleRefundMovesMoneyThenRecords(t *testing.T) {
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)
if provider != "yookassa" || refundID != "refund-"+paymentID {
t.Errorf("refund ledger row = (%s, %s), want (yookassa, refund-%s)", provider, refundID, paymentID)
}
}
@@ -502,3 +529,182 @@ func TestYooKassaOrderRequiresAnEmailAnchor(t *testing.T) {
t.Fatalf("order = %d (%s), want 403 email_required", rec.Code, rec.Body.String())
}
}
// postYooKassaRefundNotify posts a refund notification to the intake and reports the status code.
func postYooKassaRefundNotify(t *testing.T, srv *server.Server, refundID, paymentID string) int {
t.Helper()
body := fmt.Sprintf(`{"type":"notification","event":%q,"object":{"id":%q,"status":"succeeded","payment_id":%q}}`,
yookassa.EventRefundSucceeded, refundID, paymentID)
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", ykSenderIP)
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
return rec.Code
}
// fundedYooKassaOrder seeds an order and credits it, leaving an account with chips to reverse.
func fundedYooKassaOrder(t *testing.T, f *fakeYooKassa, srv *server.Server, pay *payments.Service, acc uuid.UUID) (uuid.UUID, string) {
t.Helper()
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)
}
return orderID, paymentID
}
// TestYooKassaCabinetRefundIsReversed is why the refund event is handled at all: an operator can
// refund in the merchant cabinet, which never passes through our API, so without this the money would
// go back while the chips stayed credited.
func TestYooKassaCabinetRefundIsReversed(t *testing.T) {
f, shop := newFakeYooKassa(t)
srv, pay := yookassaServer(t, shop)
acc := provisionAccount(t)
_, paymentID := fundedYooKassaOrder(t, f, srv, pay, acc)
if got := readBalance(t, acc, "direct"); got != 100 {
t.Fatalf("balance before the refund = %d, want 100", got)
}
// The refund exists at the provider, issued outside our API.
f.setRefund("cabinet-refund-1", yookassa.Refund{
Status: yookassa.StatusSucceeded, PaymentID: paymentID,
Amount: yookassa.Amount{Value: "149.00", Currency: "RUB"},
})
if code := postYooKassaRefundNotify(t, srv, "cabinet-refund-1", paymentID); code != http.StatusOK {
t.Fatalf("refund notify = %d, want 200", code)
}
if got := readBalance(t, acc, "direct"); got != 0 {
t.Errorf("balance = %d, want 0 — a cabinet refund did not revoke the chips", got)
}
if f.refundCalls != 0 {
t.Errorf("provider refunds requested = %d, want 0 — the money had already moved", f.refundCalls)
}
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 != "cabinet-refund-1" {
t.Errorf("refund ledger row = (%s, %s), want (yookassa, cabinet-refund-1)", provider, refundID)
}
// A redelivery reverses nothing a second time.
if code := postYooKassaRefundNotify(t, srv, "cabinet-refund-1", paymentID); code != http.StatusOK {
t.Fatalf("redelivered refund notify = %d, want 200", code)
}
if ledgerRows(t, acc, "refund") != 1 {
t.Errorf("refund ledger rows = %d, want 1", ledgerRows(t, acc, "refund"))
}
}
// TestYooKassaRefundNotifyAfterConsoleRefundIsANoOp checks the two refund entry points cannot double
// up: the event for a refund the console already recorded reverses nothing again.
func TestYooKassaRefundNotifyAfterConsoleRefundIsANoOp(t *testing.T) {
f, shop := newFakeYooKassa(t)
srv, pay := yookassaServer(t, shop)
acc := provisionAccount(t)
orderID, paymentID := fundedYooKassaOrder(t, f, srv, pay, acc)
base := "http://admin.test/_gm/users/" + acc.String()
if code, _ := consoleDo(srv.Handler(), http.MethodPost, base+"/refund", "order_id="+orderID.String(), "http://admin.test"); code != http.StatusOK {
t.Fatalf("console refund = %d, want 200", code)
}
if got := readBalance(t, acc, "direct"); got != 0 {
t.Fatalf("balance after the console refund = %d, want 0", got)
}
// YooKassa then notifies about that same refund.
if code := postYooKassaRefundNotify(t, srv, "refund-"+paymentID, paymentID); code != http.StatusOK {
t.Fatalf("refund notify = %d, want 200", code)
}
if ledgerRows(t, acc, "refund") != 1 {
t.Errorf("refund ledger rows = %d, want 1 (reversed once)", ledgerRows(t, acc, "refund"))
}
if abuse, loss := readRisk(t, acc); abuse || loss != 0 {
t.Errorf("risk = (abuse %v, loss %d), want none — the second reversal ran anyway", abuse, loss)
}
}
// TestYooKassaPartialRefundIsLeftToAnOperator: the reversal engine revokes exactly what the pack
// funded and rejects any other amount, so a partial refund cannot be recorded without guessing how
// many chips it costs. It must change nothing rather than guess.
func TestYooKassaPartialRefundIsLeftToAnOperator(t *testing.T) {
f, shop := newFakeYooKassa(t)
srv, pay := yookassaServer(t, shop)
acc := provisionAccount(t)
_, paymentID := fundedYooKassaOrder(t, f, srv, pay, acc)
f.setRefund("partial-1", yookassa.Refund{
Status: yookassa.StatusSucceeded, PaymentID: paymentID,
Amount: yookassa.Amount{Value: "50.00", Currency: "RUB"}, // part of the 149.00 order
})
if code := postYooKassaRefundNotify(t, srv, "partial-1", paymentID); code != http.StatusOK {
t.Fatalf("refund notify = %d, want 200", code)
}
if got := readBalance(t, acc, "direct"); got != 100 {
t.Errorf("balance = %d, want 100 — a partial refund revoked chips", got)
}
if ledgerRows(t, acc, "refund") != 0 {
t.Error("a partial refund wrote a refund ledger row")
}
}
// TestYooKassaRefundNotifyIsNotEvidence: as with a payment, the body only names a refund. One the
// provider does not confirm reverses nothing.
func TestYooKassaRefundNotifyIsNotEvidence(t *testing.T) {
f, shop := newFakeYooKassa(t)
srv, pay := yookassaServer(t, shop)
acc := provisionAccount(t)
_, paymentID := fundedYooKassaOrder(t, f, srv, pay, acc)
// A refund the provider has never heard of.
if code := postYooKassaRefundNotify(t, srv, "invented-refund", paymentID); code != http.StatusOK {
t.Fatalf("refund notify = %d, want 200", code)
}
// One that exists but is not final.
f.setRefund("pending-refund", yookassa.Refund{
Status: yookassa.StatusPending, PaymentID: paymentID,
Amount: yookassa.Amount{Value: "149.00", Currency: "RUB"},
})
if code := postYooKassaRefundNotify(t, srv, "pending-refund", paymentID); code != http.StatusOK {
t.Fatalf("refund notify = %d, want 200", code)
}
if got := readBalance(t, acc, "direct"); got != 100 {
t.Errorf("balance = %d, want 100 — an unconfirmed refund revoked chips", got)
}
if ledgerRows(t, acc, "refund") != 0 {
t.Error("an unconfirmed refund wrote a refund ledger row")
}
}
// TestYooKassaPendingRefundRecordsNothing: a refund can still be canceled while pending and the
// ledger is append-only, so the console records only a completed one. Retrying is the way out.
func TestYooKassaPendingRefundRecordsNothing(t *testing.T) {
f, shop := newFakeYooKassa(t)
srv, pay := yookassaServer(t, shop)
acc := provisionAccount(t)
orderID, _ := fundedYooKassaOrder(t, f, srv, pay, acc)
f.refundStatus = yookassa.StatusPending
base := "http://admin.test/_gm/users/" + acc.String()
_, body := consoleDo(srv.Handler(), http.MethodPost, base+"/refund", "order_id="+orderID.String(), "http://admin.test")
if !strings.Contains(body, "not completed the refund yet") || !strings.Contains(body, "nothing was recorded") {
t.Errorf("message = %q, want it to say the refund is not final and nothing was recorded", body)
}
if got := readBalance(t, acc, "direct"); got != 100 {
t.Errorf("balance = %d, want 100 — chips were revoked for an unsettled refund", got)
}
if ledgerRows(t, acc, "refund") != 0 {
t.Error("a pending refund wrote a refund ledger row")
}
// Once the provider settles it, pressing again records the reversal — the idempotency key returns
// the same refund rather than paying twice.
f.refundStatus = yookassa.StatusSucceeded
if code, body := consoleDo(srv.Handler(), http.MethodPost, base+"/refund", "order_id="+orderID.String(), "http://admin.test"); code != http.StatusOK || !strings.Contains(body, "revoked 100 chips") {
t.Fatalf("retried refund = %d, body has 'revoked 100 chips' = %v", code, strings.Contains(body, "revoked 100 chips"))
}
if got := readBalance(t, acc, "direct"); got != 0 {
t.Errorf("balance = %d, want 0", got)
}
}
@@ -113,6 +113,16 @@ func (s *Service) OrderProviderRef(ctx context.Context, orderID uuid.UUID) (Orde
return orderRef(o)
}
// OrderByProviderPayment reads the order a provider's payment id belongs to. It resolves a provider
// event that names only its own payment — such as a refund notification — back to an order.
func (s *Service) OrderByProviderPayment(ctx context.Context, provider, providerPaymentID string) (OrderRef, error) {
o, err := s.store.orderByProviderPayment(ctx, provider, providerPaymentID)
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
+23
View File
@@ -229,6 +229,29 @@ func derefString(p *string) string {
return *p
}
// orderByProviderPayment reads the order a provider's payment id belongs to, or ErrOrderNotFound.
// It is how a provider event that names only its own payment — a refund notification, say — is
// resolved back to an order.
func (s *Store) orderByProviderPayment(ctx context.Context, provider, providerPaymentID string) (orderRow, error) {
if provider == "" || providerPaymentID == "" {
return orderRow{}, ErrOrderNotFound
}
var o model.Orders
err := postgres.SELECT(table.Orders.AllColumns).
FROM(table.Orders).
WHERE(table.Orders.Provider.EQ(postgres.String(provider)).
AND(table.Orders.ProviderPaymentID.EQ(postgres.String(providerPaymentID)))).
LIMIT(1).
QueryContext(ctx, s.db, &o)
if errors.Is(err, qrm.ErrNoRows) {
return orderRow{}, ErrOrderNotFound
}
if err != nil {
return orderRow{}, fmt.Errorf("payments: load order by %s payment %s: %w", provider, providerPaymentID, err)
}
return newOrderRow(o), nil
}
// 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
@@ -3,6 +3,7 @@ package server
import (
"context"
"encoding/csv"
"errors"
"fmt"
"strconv"
"strings"
@@ -71,6 +72,12 @@ func (s *Server) refundOrder(ctx context.Context, ref payments.OrderRef) (paymen
return s.payments.RefundOrderFull(ctx, ref.OrderID)
}
refundID, err := s.refundYooKassa(ctx, ref)
if errors.Is(err, errRefundNotFinal) {
// The money is on its way but not settled. Nothing is recorded yet; pressing again is safe —
// the idempotency key returns the same refund rather than paying a second time.
s.log.Warn("yookassa refund not final", zap.String("order", ref.OrderID.String()), zap.Error(err))
return payments.RefundOutcome{}, fmt.Errorf("%w — nothing was recorded; try again in a minute", err)
}
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)
+105 -2
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
@@ -111,9 +112,11 @@ func (s *Server) handleYooKassaNotify(c *gin.Context) {
}
switch note.Event {
case yookassa.EventPaymentSucceeded, yookassa.EventPaymentCanceled:
case yookassa.EventRefundSucceeded:
s.handleYooKassaRefundNotification(c, note)
return
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.
// A subscription we do not act on.
c.String(http.StatusOK, "ignored")
return
}
@@ -189,6 +192,95 @@ func (s *Server) handleYooKassaNotify(c *gin.Context) {
c.String(http.StatusOK, "ok")
}
// handleYooKassaRefundNotification reverses a refund the provider reports as completed. Its reason
// for existing is the merchant cabinet: an operator can refund there, and such a refund never passes
// through our API, so without this the money would go back while the chips stayed credited.
//
// As with a payment, the body is only a hint — the refund is re-read from the API before anything is
// recorded. The reversal is idempotent on (yookassa, refund id), so the notification for a refund the
// console already recorded reverses nothing a second time.
func (s *Server) handleYooKassaRefundNotification(c *gin.Context, note yookassa.Notification) {
claimed, err := note.Refund()
if err != nil {
s.log.Warn("yookassa notify: unusable refund object", zap.Error(err))
c.String(http.StatusOK, "ignored")
return
}
ctx := c.Request.Context()
// The refund object names the payment, not our order, so the order is found by the payment id we
// recorded when the payment was minted. The claim is untrusted, but it only selects which shop's
// credentials perform the confirming read; the confirmed refund is then bound back to this order.
ref, err := s.payments.OrderByProviderPayment(ctx, providerYooKassa, claimed.PaymentID)
if errors.Is(err, payments.ErrOrderNotFound) {
s.log.Warn("yookassa refund notify: no order for the payment",
zap.String("payment", claimed.PaymentID), zap.String("refund", claimed.ID))
c.String(http.StatusOK, "ignored")
return
}
if err != nil {
s.log.Error("yookassa refund notify: order lookup failed", zap.String("payment", claimed.PaymentID), zap.Error(err))
c.String(http.StatusInternalServerError, "error")
return
}
shop, ok := s.yookassa.Shop(ref.Shop)
if !ok {
s.log.Warn("yookassa refund notify: no shop for the order's channel", zap.String("order", ref.OrderID.String()))
c.String(http.StatusOK, "ignored")
return
}
refund, err := shop.GetRefund(ctx, claimed.ID)
if err != nil {
var apiErr *yookassa.APIError
if errors.As(err, &apiErr) && !apiErr.Retryable() {
s.log.Warn("yookassa refund notify: refund not confirmed",
zap.String("order", ref.OrderID.String()), zap.String("refund", claimed.ID), zap.Error(err))
c.String(http.StatusOK, "ignored")
return
}
s.log.Error("yookassa refund notify: confirm failed",
zap.String("order", ref.OrderID.String()), zap.String("refund", claimed.ID), zap.Error(err))
c.String(http.StatusInternalServerError, "error")
return
}
if refund.Status != yookassa.StatusSucceeded || refund.PaymentID != ref.PaymentID {
s.log.Warn("yookassa refund notify: confirmed refund does not settle this order",
zap.String("order", ref.OrderID.String()), zap.String("refund", refund.ID),
zap.String("status", refund.Status), zap.String("refund_payment", refund.PaymentID))
c.String(http.StatusOK, "ignored")
return
}
// The reversal engine is full-refund-only by design: it revokes exactly what the pack funded and
// rejects any other amount. A partial refund therefore cannot be recorded without guessing how
// many chips it should cost, so it is left to an operator — loudly, because the money has moved.
paid, err := payments.ParseMoney(refund.Amount.Value, payments.Currency(refund.Amount.Currency))
if err != nil || paid.Currency() != ref.Amount.Currency() || paid.Minor() != ref.Amount.Minor() {
s.log.Error("yookassa refund notify: partial refund needs an operator; nothing was reversed",
zap.String("order", ref.OrderID.String()), zap.String("refund", refund.ID),
zap.String("refunded", refund.Amount.Value), zap.String("order_amount", ref.Amount.Major()))
c.String(http.StatusOK, "ignored")
return
}
out, err := s.payments.RefundOrderFullAs(ctx, ref.OrderID, providerYooKassa, refund.ID)
if err != nil {
if errors.Is(err, payments.ErrOrderNotPaid) {
s.log.Warn("yookassa refund notify: the order was never credited",
zap.String("order", ref.OrderID.String()), zap.String("refund", refund.ID))
c.String(http.StatusOK, "ignored")
return
}
s.log.Error("yookassa refund notify: reversal failed",
zap.String("order", ref.OrderID.String()), zap.String("refund", refund.ID), zap.Error(err))
c.String(http.StatusInternalServerError, "error")
return
}
if !out.AlreadyRefunded {
s.log.Info("yookassa refund notify: reversed a refund issued outside the console",
zap.String("order", ref.OrderID.String()), zap.String("refund", refund.ID),
zap.Int("revoked", out.Revoked), zap.Int("loss", out.Loss))
}
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
@@ -305,9 +397,20 @@ func (s *Server) refundYooKassa(ctx context.Context, ref payments.OrderRef) (str
if refund.ID == "" {
return "", errors.New("the provider returned a refund with no id")
}
// Only a completed refund is recorded. A refund can still be canceled while pending, and the
// ledger is append-only, so recording early would mean revoking a customer's chips for money that
// then stayed with us — with no way to take the row back.
if refund.Status != yookassa.StatusSucceeded {
return "", fmt.Errorf("%w (status %s)", errRefundNotFinal, refund.Status)
}
return refund.ID, nil
}
// errRefundNotFinal reports that the provider accepted the refund but has not completed it. Retrying
// is safe and is the way out: the idempotency key returns the same refund rather than paying twice,
// so the operator can press again until it settles.
var errRefundNotFinal = errors.New("the provider has not completed the refund yet")
// 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
+16 -2
View File
@@ -14,8 +14,9 @@ const (
// 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 reports a completed refund. It matters because the merchant cabinet is a
// second way to issue one: a refund made there never passes through our API, so without this
// event the money would go back while the chips stayed credited.
EventRefundSucceeded = "refund.succeeded"
)
@@ -58,6 +59,19 @@ func (n Notification) Payment() (Payment, error) {
return p, nil
}
// Refund decodes the notification's object as a refund. Use it only for refund.* events; it yields
// the refund id to re-read, never the refund state to act on.
func (n Notification) Refund() (Refund, error) {
var r Refund
if err := json.Unmarshal(n.Object, &r); err != nil {
return Refund{}, fmt.Errorf("yookassa: decode notification refund: %w", err)
}
if r.ID == "" {
return Refund{}, fmt.Errorf("yookassa: notification refund has no id")
}
return r, 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
+30
View File
@@ -88,3 +88,33 @@ func TestAllowedSenderUnmapsIPv4(t *testing.T) {
t.Error("an IPv4-mapped foreign sender was allowed")
}
}
func TestParseRefundNotification(t *testing.T) {
n, err := ParseNotification([]byte(`{"type":"notification","event":"refund.succeeded",
"object":{"id":"refund-1","status":"succeeded","payment_id":"pay-1",
"amount":{"value":"149.00","currency":"RUB"}}}`))
if err != nil {
t.Fatalf("parse notification: %v", err)
}
if n.Event != EventRefundSucceeded {
t.Errorf("event = %q, want %q", n.Event, EventRefundSucceeded)
}
r, err := n.Refund()
if err != nil {
t.Fatalf("decode notification refund: %v", err)
}
// The refund names the payment, not our order — that is how it is resolved back to one.
if r.ID != "refund-1" || r.PaymentID != "pay-1" {
t.Errorf("refund = %+v, want refund-1 for pay-1", r)
}
}
func TestNotificationRefundNeedsAnID(t *testing.T) {
n, err := ParseNotification([]byte(`{"type":"notification","event":"refund.succeeded","object":{"status":"succeeded"}}`))
if err != nil {
t.Fatalf("parse notification: %v", err)
}
if _, err := n.Refund(); err == nil {
t.Error("a refund object with no id was accepted")
}
}
+10 -1
View File
@@ -207,13 +207,22 @@ func (c Config) GetPayment(ctx context.Context, paymentID string) (Payment, erro
}
// 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.
// guards against a double refund on a retry; callers pass the order id. The returned refund is not
// necessarily final — check Status before acting on it.
func (c Config) CreateRefund(ctx context.Context, req RefundRequest, idempotenceKey string) (Refund, error) {
var out Refund
err := c.do(ctx, http.MethodPost, "/refunds", req, idempotenceKey, &out)
return out, err
}
// GetRefund re-reads a refund by id. As with GetPayment this is the authenticity check: a refund
// notification is unsigned, so only the object returned here may be acted on.
func (c Config) GetRefund(ctx context.Context, refundID string) (Refund, error) {
var out Refund
err := c.do(ctx, http.MethodGet, "/refunds/"+url.PathEscape(refundID), nil, "", &out)
return out, err
}
// do performs one authenticated API call and decodes the result into out. A non-2xx answer is
// returned as an *APIError carrying YooKassa's own error object when the body holds one.
func (c Config) do(ctx context.Context, method, path string, body any, idempotenceKey string, out any) error {
@@ -223,3 +223,38 @@ func TestEndpointDefaultsToTheProductionAPI(t *testing.T) {
t.Errorf("endpoint = %q, want the trailing slash trimmed", got)
}
}
func TestGetRefundReadsTheObject(t *testing.T) {
cfg, got := fakeAPI(t, http.StatusOK, `{
"id":"refund-1","status":"succeeded","payment_id":"pay-1",
"amount":{"value":"149.00","currency":"RUB"}}`)
r, err := cfg.GetRefund(context.Background(), "refund-1")
if err != nil {
t.Fatalf("get refund: %v", err)
}
if got.method != http.MethodGet || got.path != "/refunds/refund-1" {
t.Errorf("request = %s %s, want GET /refunds/refund-1", got.method, got.path)
}
if r.Status != StatusSucceeded || r.PaymentID != "pay-1" {
t.Errorf("refund = %+v, want a succeeded refund of pay-1", r)
}
}
func TestCreateRefundReportsANonFinalStatus(t *testing.T) {
// A refund can be accepted and still be canceled later, so the caller must be able to see that it
// is not settled rather than treat any 200 as done.
cfg, _ := fakeAPI(t, http.StatusOK, `{
"id":"refund-1","status":"pending","payment_id":"pay-1",
"amount":{"value":"149.00","currency":"RUB"}}`)
r, err := cfg.CreateRefund(context.Background(), RefundRequest{
PaymentID: "pay-1", Amount: Amount{Value: "149.00", Currency: "RUB"},
}, "0197-order")
if err != nil {
t.Fatalf("create refund: %v", err)
}
if r.Status != StatusPending {
t.Errorf("status = %q, want pending surfaced to the caller", r.Status)
}
}