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
@@ -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