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 }