4cac09c9f3
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 21s
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 1m55s
A real test payment on the contour exposed both problems at once. YooKassa
delivered the notification five times; all five were rejected because the
backend saw the sender as 10.77.0.1 — the contour sits behind a tunnel and
cannot observe real client addresses, the same reason the IP bans in this
repository are prod-only. The chips were not lost (the reconcile sweep would
have credited them), but the primary path was dead and the customer was left
watching an unchanged balance.
The address check is removed rather than made conditional. It never was the
security boundary — the confirming GET /v3/payments/{id} is — and the one thing
it bought is already bought earlier and far more tightly: the order is resolved
from the notification's metadata *before* any provider call, so a notification
naming no known order costs a single indexed read and stops there. Guessing a
live order id means guessing a uuid. Against that, an address check adds nothing
and breaks every deployment that cannot see real client addresses, while turning
any future change to YooKassa's published ranges into a silent degradation.
The second problem was mine. The reconcile threshold was keyed off the order
lifetime, so a lost notification cost the customer the full 30-minute TTL before
the chips landed. Those are different questions: the lifetime governs how long a
customer may take to pay, the re-check governs how soon we notice a lost
callback. Split apart — `payments.ReconcileAfter`, one minute, swept on every
reaper tick. The bound D49 was chosen for survives: the calls one order can
cause are still its lifetime divided by the sweep interval, a handful, not an
open-ended poll. Worst case for a failed notification drops from ~30 minutes to
~5; an order the customer is still paying for is left alone.
Tests: the foreign-sender test is replaced by the two properties that now carry
the load — a notification naming an unknown order makes no provider call at all,
and a genuine notification is honoured whatever address it appears to come from.
Plus one pinning that a seconds-old order is not polled.
The shared bundle budget goes 31 -> 32 KB, with the reason recorded in the
script header: every user-visible string lands in that chunk and it had been
sitting 40 bytes under the cap.
Decisions D48 and D49 revised.
471 lines
22 KiB
Go
471 lines
22 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"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
|
|
|
|
// yooKassaReceipt builds the fiscal receipt for a sale, or nil when receipts are off. They are off
|
|
// by default: the merchant runs on НПД, which is outside 54-ФЗ, so nothing is registered through the
|
|
// provider and each operation is reported to «Мой налог» instead. Setting a VAT rate code turns them
|
|
// back on — the switch a lost НПД regime would need — without touching this code.
|
|
func (s *Server) yooKassaReceipt(email, title string, amount yookassa.Amount) *yookassa.Receipt {
|
|
if !yookassa.ReceiptEnabled(s.vatCode) {
|
|
return nil
|
|
}
|
|
return yookassa.SingleItemReceipt(email, title, amount, s.vatCode)
|
|
}
|
|
|
|
// 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) — the recovery anchor a direct purchase requires,
|
|
// and the delivery address of a fiscal receipt when receipts are switched on. 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()},
|
|
// Off unless a VAT rate is configured: the merchant is outside 54-ФЗ and reports each
|
|
// operation itself, so no fiscal receipt is registered through the provider.
|
|
Receipt: s.yooKassaReceipt(email, res.Title, amount),
|
|
}, 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 sender address is deliberately NOT checked. It would not add security — the confirming read is
|
|
// the whole boundary — and the one thing it did buy, keeping a forger from turning each fabricated
|
|
// notification into an outbound call of ours, is already bought earlier and far more tightly: the
|
|
// order is resolved from the notification's metadata first, and an id that matches no order costs a
|
|
// single indexed read and nothing else. Guessing a live order id means guessing a uuid. Meanwhile an
|
|
// address check is actively harmful in a deployment that cannot observe real client addresses (a
|
|
// contour behind a tunnel sees only its own), where it rejects genuine notifications.
|
|
//
|
|
// 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).
|
|
func (s *Server) handleYooKassaNotify(c *gin.Context) {
|
|
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:
|
|
case yookassa.EventRefundSucceeded:
|
|
s.handleYooKassaRefundNotification(c, note)
|
|
return
|
|
default:
|
|
// 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")
|
|
}
|
|
|
|
// handleYooKassaRefundNotification reverses a refund the provider reports as completed. Its reason
|
|
// for existing is the merchant cabinet: an operator can refund there, and such a refund never passes
|
|
// through our API, so without this the money would go back while the chips stayed credited.
|
|
//
|
|
// As with a payment, the body is only a hint — the refund is re-read from the API before anything is
|
|
// recorded. The reversal is idempotent on (yookassa, refund id), so the notification for a refund the
|
|
// console already recorded reverses nothing a second time.
|
|
func (s *Server) handleYooKassaRefundNotification(c *gin.Context, note yookassa.Notification) {
|
|
claimed, err := note.Refund()
|
|
if err != nil {
|
|
s.log.Warn("yookassa notify: unusable refund object", zap.Error(err))
|
|
c.String(http.StatusOK, "ignored")
|
|
return
|
|
}
|
|
ctx := c.Request.Context()
|
|
// The refund object names the payment, not our order, so the order is found by the payment id we
|
|
// recorded when the payment was minted. The claim is untrusted, but it only selects which shop's
|
|
// credentials perform the confirming read; the confirmed refund is then bound back to this order.
|
|
ref, err := s.payments.OrderByProviderPayment(ctx, providerYooKassa, claimed.PaymentID)
|
|
if errors.Is(err, payments.ErrOrderNotFound) {
|
|
s.log.Warn("yookassa refund notify: no order for the payment",
|
|
zap.String("payment", claimed.PaymentID), zap.String("refund", claimed.ID))
|
|
c.String(http.StatusOK, "ignored")
|
|
return
|
|
}
|
|
if err != nil {
|
|
s.log.Error("yookassa refund notify: order lookup failed", zap.String("payment", claimed.PaymentID), zap.Error(err))
|
|
c.String(http.StatusInternalServerError, "error")
|
|
return
|
|
}
|
|
shop, ok := s.yookassa.Shop(ref.Shop)
|
|
if !ok {
|
|
s.log.Warn("yookassa refund notify: no shop for the order's channel", zap.String("order", ref.OrderID.String()))
|
|
c.String(http.StatusOK, "ignored")
|
|
return
|
|
}
|
|
refund, err := shop.GetRefund(ctx, claimed.ID)
|
|
if err != nil {
|
|
var apiErr *yookassa.APIError
|
|
if errors.As(err, &apiErr) && !apiErr.Retryable() {
|
|
s.log.Warn("yookassa refund notify: refund not confirmed",
|
|
zap.String("order", ref.OrderID.String()), zap.String("refund", claimed.ID), zap.Error(err))
|
|
c.String(http.StatusOK, "ignored")
|
|
return
|
|
}
|
|
s.log.Error("yookassa refund notify: confirm failed",
|
|
zap.String("order", ref.OrderID.String()), zap.String("refund", claimed.ID), zap.Error(err))
|
|
c.String(http.StatusInternalServerError, "error")
|
|
return
|
|
}
|
|
if refund.Status != yookassa.StatusSucceeded || refund.PaymentID != ref.PaymentID {
|
|
s.log.Warn("yookassa refund notify: confirmed refund does not settle this order",
|
|
zap.String("order", ref.OrderID.String()), zap.String("refund", refund.ID),
|
|
zap.String("status", refund.Status), zap.String("refund_payment", refund.PaymentID))
|
|
c.String(http.StatusOK, "ignored")
|
|
return
|
|
}
|
|
// The reversal engine is full-refund-only by design: it revokes exactly what the pack funded and
|
|
// rejects any other amount. A partial refund therefore cannot be recorded without guessing how
|
|
// many chips it should cost, so it is left to an operator — loudly, because the money has moved.
|
|
paid, err := payments.ParseMoney(refund.Amount.Value, payments.Currency(refund.Amount.Currency))
|
|
if err != nil || paid.Currency() != ref.Amount.Currency() || paid.Minor() != ref.Amount.Minor() {
|
|
s.log.Error("yookassa refund notify: partial refund needs an operator; nothing was reversed",
|
|
zap.String("order", ref.OrderID.String()), zap.String("refund", refund.ID),
|
|
zap.String("refunded", refund.Amount.Value), zap.String("order_amount", ref.Amount.Major()))
|
|
c.String(http.StatusOK, "ignored")
|
|
return
|
|
}
|
|
out, err := s.payments.RefundOrderFullAs(ctx, ref.OrderID, providerYooKassa, refund.ID)
|
|
if err != nil {
|
|
if errors.Is(err, payments.ErrOrderNotPaid) {
|
|
s.log.Warn("yookassa refund notify: the order was never credited",
|
|
zap.String("order", ref.OrderID.String()), zap.String("refund", refund.ID))
|
|
c.String(http.StatusOK, "ignored")
|
|
return
|
|
}
|
|
s.log.Error("yookassa refund notify: reversal failed",
|
|
zap.String("order", ref.OrderID.String()), zap.String("refund", refund.ID), zap.Error(err))
|
|
c.String(http.StatusInternalServerError, "error")
|
|
return
|
|
}
|
|
if !out.AlreadyRefunded {
|
|
s.log.Info("yookassa refund notify: reversed a refund issued outside the console",
|
|
zap.String("order", ref.OrderID.String()), zap.String("refund", refund.ID),
|
|
zap.Int("revoked", out.Revoked), zap.Int("loss", out.Loss))
|
|
}
|
|
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. It sends the order id as the idempotency
|
|
// key, so a repeated attempt returns the original refund instead of paying twice, and a refund
|
|
// receipt when receipts are switched on (the rail registers one the same way it does for a payment).
|
|
//
|
|
// 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
|
|
}
|
|
// The buyer's address is read only to deliver a refund receipt; with receipts off there is
|
|
// nothing to deliver and nothing to read.
|
|
email := ""
|
|
if yookassa.ReceiptEnabled(s.vatCode) {
|
|
if email, _, err = s.accounts.ConfirmedEmail(ctx, ref.AccountID); 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: s.yooKassaReceipt(email, title, amount),
|
|
}, ref.OrderID.String())
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if refund.ID == "" {
|
|
return "", errors.New("the provider returned a refund with no id")
|
|
}
|
|
// Only a completed refund is recorded. A refund can still be canceled while pending, and the
|
|
// ledger is append-only, so recording early would mean revoking a customer's chips for money that
|
|
// then stayed with us — with no way to take the row back.
|
|
if refund.Status != yookassa.StatusSucceeded {
|
|
return "", fmt.Errorf("%w (status %s)", errRefundNotFinal, refund.Status)
|
|
}
|
|
return refund.ID, nil
|
|
}
|
|
|
|
// errRefundNotFinal reports that the provider accepted the refund but has not completed it. Retrying
|
|
// is safe and is the way out: the idempotency key returns the same refund rather than paying twice,
|
|
// so the operator can press again until it settles.
|
|
var errRefundNotFinal = errors.New("the provider has not completed the refund yet")
|
|
|
|
// 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
|
|
}
|