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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user