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