Files
scrabble-game/backend/internal/server/handlers_admin_refund.go
T
Ilia Denisov e3961fe4ca
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 22s
CI / ui (pull_request) Successful in 1m16s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 1s
CI / deploy (pull_request) Successful in 2m28s
fix(payments): a console refund that its own notification beat is not a repeat
Refunding from /_gm reported "Already refunded" for a refund that had just
succeeded, which reads as "you clicked twice".

The two recording paths race. YooKassa fires refund.succeeded the moment
POST /v3/refunds returns, so the notification handler often writes the reversal
before the console's own write lands. Both name the same refund id, so the
ledger's idempotency index rejects the second — which is the mechanism working
exactly as intended: on the contour the money moved once, one refund row was
written, the balance is right and no abuse flag was raised. Only the message
was wrong about why.

The console now reports success on that path, and the notification's log line
no longer claims the refund was "issued outside the console" when it may well
have come from it.

Covered by an integration test that lands the notification first and then
refunds from the console, asserting the operator is told it succeeded and that
exactly one refund row exists.
2026-07-28 12:15:59 +02:00

120 lines
4.9 KiB
Go

package server
import (
"context"
"encoding/csv"
"errors"
"fmt"
"strconv"
"strings"
"time"
"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: 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 {
return
}
back := "/_gm/users/" + id.String()
if s.payments == nil {
s.renderConsoleMessage(c, "Unavailable", "payments are not enabled", back)
return
}
orderID, err := uuid.Parse(strings.TrimSpace(c.PostForm("order_id")))
if err != nil {
s.renderConsoleMessage(c, "Refund failed", "no order to refund", back)
return
}
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
}
if out.AlreadyRefunded {
if ref.Provider == providerYooKassa {
// The provider refunded the money on this very click, so this is a success, not a repeat.
// The reversal was already in the ledger because the provider's own refund notification
// reached us first — it names the same refund id, which is exactly why the idempotency
// index caught it and nothing was revoked twice. Saying "already refunded" here would read
// as "you clicked twice".
s.renderConsoleMessage(c, "Refunded", "the money is back with the customer; the reversal was already recorded, so nothing changed just now", back)
return
}
s.renderConsoleMessage(c, "Already refunded", "this order was already refunded", back)
return
}
s.publishBannerChange(id)
msg := fmt.Sprintf("revoked %d chips", out.Revoked)
if out.Loss > 0 {
msg += fmt.Sprintf("; %d chips were already spent (recorded as a loss + abuse flag)", out.Loss)
}
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 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)
}
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) {
rows, err := s.payments.LedgerExport(c.Request.Context())
if err != nil {
s.consoleError(c, err)
return
}
c.Header("Content-Type", "text/csv; charset=utf-8")
c.Header("Content-Disposition", `attachment; filename="ledger.csv"`)
w := csv.NewWriter(c.Writer)
_ = w.Write([]string{
"created_at", "account_id", "kind", "source", "origin", "chips_delta",
"product_id", "order_id", "provider", "provider_payment_id", "snapshot",
})
for _, r := range rows {
_ = w.Write([]string{
r.CreatedAt.UTC().Format(time.RFC3339), r.AccountID, r.Kind, r.Source, r.Origin,
strconv.Itoa(r.ChipsDelta), r.ProductID, r.OrderID, r.Provider, r.ProviderPaymentID, r.Snapshot,
})
}
w.Flush()
}