3b744b7d2f
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 12s
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 0s
CI / deploy (pull_request) Successful in 1m47s
There was no way to see the money as a whole: the ledger was only ever rendered inside one account's card, so "what came in last month" meant exporting the entire CSV and reading it elsewhere. /_gm/ledger lists every operation, newest first, filtered by date range (defaulting to the last 30 days), by wallet, by rail, by kind and by account. Wallet and rail are deliberately separate axes because they answer different questions — "what happened on VK" matches the funded segment or the benefit origin, while "what came through YooKassa" matches the settling provider, and a chip spend has no rail at all, so it drops out of that filter by construction. Above the table sit the totals for everything the filter matches, not merely the page on screen, which is the point of showing them. Money is listed per currency because the rails settle in roubles, Votes and Stars and one sum across them would mean nothing. Row amounts come from the operation snapshot, since the ledger's own columns count chips rather than money. Paging, the CSV export and a refund's way back are all built from the same encoded filter, so none of them can quietly show a different slice than the screen. The export moves here from the user card and gains the filter along with money columns; it is capped, because an append-only ledger grows forever and an unbounded export would eventually time out. The refund action moves onto the funded rows here — an operator can now find a payment by filter without knowing whose it is first — and returns to the same filtered view. The destination travels with the form but is only honoured when it is a console path, so a crafted form cannot turn it into an open redirect. The user card keeps the account's standing rather than its history: balances, benefits, the risk flag and a lifetime summary (money paid and refunded per currency, chips credited and spent), plus a link into the ledger scoped to that account. Operations are rendered in one place, with filters and paging, instead of two. Tests: the filter axes, paging that neither repeats nor drops a row, totals that describe the range rather than the page, money recovered from a snapshot and absent on a spend, and the console page, the export and the card hand-off. The existing finance-panel test now asserts the summary and that the card no longer re-renders the rows.
105 lines
4.6 KiB
Go
105 lines
4.6 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"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 := consoleReturnTo(c.PostForm("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)
|
|
}
|
|
|
|
// consoleReturnTo picks where the result page links back to: the caller's requested destination when
|
|
// it is a console page, else the fallback. A refund can be started from the account card or from the
|
|
// ledger (where the operator wants to land back on the same filtered slice), so the destination
|
|
// travels with the form — but only ever as a console-relative path, never as an arbitrary URL a
|
|
// crafted form could point elsewhere.
|
|
func consoleReturnTo(requested, fallback string) string {
|
|
if strings.HasPrefix(requested, "/_gm/") && !strings.HasPrefix(requested, "/_gm//") {
|
|
return requested
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
// 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)
|
|
}
|