From 36a5730e52b8ffeef3f2b79f002e6ee8294089b6 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Thu, 9 Jul 2026 16:59:34 +0200 Subject: [PATCH] feat(payments): direct-rail order + intake handlers, config and reaper Wire the Robokassa direct rail into the backend transport. POST /api/v1/user/wallet/order (walletGate + a D36 confirmed-email gate for the direct rail) opens a pending order and returns the signed Robokassa payment URL. The internal, gateway-only /payments/robokassa/result endpoint verifies the Result signature, credits the matched order exactly once via Fund (honoured even if expired), records a succeeded payment event, and answers Robokassa's "OK". Add the Robokassa env config, an account HasConfirmedEmail check (D36), the payment_events writer, and a periodic pending-order reaper. The routes register only when a Robokassa merchant login is configured. --- backend/cmd/backend/main.go | 25 ++++ backend/internal/account/account.go | 15 +++ backend/internal/config/config.go | 15 +++ backend/internal/payments/service_intake.go | 6 + backend/internal/payments/store_intake.go | 21 ++++ backend/internal/server/handlers.go | 8 ++ backend/internal/server/handlers_intake.go | 125 ++++++++++++++++++++ backend/internal/server/server.go | 6 + 8 files changed, 221 insertions(+) create mode 100644 backend/internal/server/handlers_intake.go diff --git a/backend/cmd/backend/main.go b/backend/cmd/backend/main.go index 31ae7ac..6c3fc64 100644 --- a/backend/cmd/backend/main.go +++ b/backend/cmd/backend/main.go @@ -308,6 +308,7 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error { Notifier: hub, ExportSignKey: cfg.ExportSignKey, Renderer: renderer, + Robokassa: cfg.Robokassa, }) pushSrv := pushgrpc.NewServer(cfg.GRPCAddr, hub, logger) @@ -316,6 +317,10 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error { logger.Info("servers starting", zap.String("http_addr", cfg.HTTPAddr), zap.String("grpc_addr", cfg.GRPCAddr)) + // Sweep expired pending payment orders on a cadence (cosmetic hygiene; a late valid callback + // still credits). Runs until ctx is cancelled. + go runOrderReaper(ctx, paymentsSvc, logger) + errc := make(chan error, 2) go func() { errc <- pushSrv.Run(ctx) }() go func() { errc <- srv.Run(ctx) }() @@ -325,6 +330,26 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error { return err } +// runOrderReaper periodically expires pending payment orders past their configured lifetime, until +// ctx is cancelled. Expiry is cosmetic: a later valid provider callback still credits an expired +// order. +func runOrderReaper(ctx context.Context, p *payments.Service, log *zap.Logger) { + t := time.NewTicker(5 * time.Minute) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + if n, err := p.ExpireOrders(ctx); err != nil { + log.Warn("order reaper: sweep failed", zap.Error(err)) + } else if n > 0 { + log.Info("order reaper: expired pending orders", zap.Int("count", n)) + } + } + } +} + // newMailer builds the confirm-code mailer: an SMTP relay when a host is // configured, otherwise the development log mailer (the code is logged, not sent). func newMailer(cfg account.SMTPConfig, logger *zap.Logger) account.Mailer { diff --git a/backend/internal/account/account.go b/backend/internal/account/account.go index 2b10fc8..7ff1018 100644 --- a/backend/internal/account/account.go +++ b/backend/internal/account/account.go @@ -393,6 +393,21 @@ func (s *Store) Identities(ctx context.Context, accountID uuid.UUID) ([]Identity return out, nil } +// HasConfirmedEmail reports whether the account owns a confirmed email identity — the direct-rail +// recovery anchor a first purchase requires (D36). +func (s *Store) HasConfirmedEmail(ctx context.Context, accountID uuid.UUID) (bool, error) { + ids, err := s.Identities(ctx, accountID) + if err != nil { + return false, err + } + for _, id := range ids { + if id.Kind == "email" && id.Confirmed { + return true, nil + } + } + return false, nil +} + // ListAccounts returns accounts for the admin user list, newest first, paginated // by limit and offset. func (s *Store) ListAccounts(ctx context.Context, limit, offset int) ([]Account, error) { diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 2826370..f3844ee 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -14,6 +14,7 @@ import ( "scrabble/backend/internal/lobby" "scrabble/backend/internal/postgres" "scrabble/backend/internal/ratewatch" + "scrabble/backend/internal/robokassa" "scrabble/backend/internal/robot" "scrabble/backend/internal/telemetry" ) @@ -64,6 +65,9 @@ type Config struct { // RendererURL is the base URL of the internal image-render sidecar (e.g. // http://renderer:8090). Empty disables the PNG export artifact. RendererURL string + // Robokassa configures the direct-rail (RUB) payment provider. An empty MerchantLogin + // leaves the direct order and Result-callback endpoints unregistered. + Robokassa robokassa.Config } // Defaults applied when the corresponding environment variable is unset. @@ -153,6 +157,13 @@ func Load() (Config, error) { AdminTo: os.Getenv("BACKEND_ADMIN_EMAIL"), } + robo := robokassa.Config{ + MerchantLogin: os.Getenv("BACKEND_ROBOKASSA_MERCHANT_LOGIN"), + Password1: os.Getenv("BACKEND_ROBOKASSA_PASSWORD1"), + Password2: os.Getenv("BACKEND_ROBOKASSA_PASSWORD2"), + IsTest: os.Getenv("BACKEND_ROBOKASSA_TEST") == "1", + } + c := Config{ HTTPAddr: envOr("BACKEND_HTTP_ADDR", defaultHTTPAddr), GRPCAddr: envOr("BACKEND_GRPC_ADDR", defaultGRPCAddr), @@ -170,6 +181,7 @@ func Load() (Config, error) { GuestRetention: guestRetention, ExportSignKey: os.Getenv("BACKEND_EXPORT_SIGN_KEY"), RendererURL: os.Getenv("BACKEND_RENDERER_URL"), + Robokassa: robo, } if err := c.validate(); err != nil { return Config{}, err @@ -222,6 +234,9 @@ func (c Config) validate() error { return fmt.Errorf("config: BACKEND_PUBLIC_BASE_URL %q must be an absolute URL (scheme://host)", c.PublicBaseURL) } } + if c.Robokassa.MerchantLogin != "" && (c.Robokassa.Password1 == "" || c.Robokassa.Password2 == "") { + return fmt.Errorf("config: BACKEND_ROBOKASSA_PASSWORD1 and BACKEND_ROBOKASSA_PASSWORD2 must be set when BACKEND_ROBOKASSA_MERCHANT_LOGIN is") + } return nil } diff --git a/backend/internal/payments/service_intake.go b/backend/internal/payments/service_intake.go index 03dcd33..e57aac9 100644 --- a/backend/internal/payments/service_intake.go +++ b/backend/internal/payments/service_intake.go @@ -71,3 +71,9 @@ func (s *Service) ExpireOrders(ctx context.Context) (int, error) { } return s.store.expirePending(ctx, ttl, s.clock()) } + +// RecordPaymentEvent appends a payment lifecycle event (succeeded/failed/refunded) for the +// dispatcher to deliver to the user (live stream, botlink or email). +func (s *Service) RecordPaymentEvent(ctx context.Context, accountID uuid.UUID, orderID *uuid.UUID, eventType string, payload []byte) error { + return s.store.insertPaymentEvent(ctx, accountID, orderID, eventType, payload, s.clock()) +} diff --git a/backend/internal/payments/store_intake.go b/backend/internal/payments/store_intake.go index 9bc8796..e9b49ae 100644 --- a/backend/internal/payments/store_intake.go +++ b/backend/internal/payments/store_intake.go @@ -271,6 +271,27 @@ func (s *Store) fund(ctx context.Context, orderID uuid.UUID, provider, providerP return outcome, nil } +// insertPaymentEvent appends an undispatched lifecycle event (succeeded/failed/refunded) for the +// dispatcher to deliver. orderID and payload (a jsonb detail blob) are optional. +func (s *Store) insertPaymentEvent(ctx context.Context, accountID uuid.UUID, orderID *uuid.UUID, eventType string, payload []byte, now time.Time) error { + id, err := uuid.NewV7() + if err != nil { + return fmt.Errorf("payments: event id: %w", err) + } + var pl any = postgres.NULL + if payload != nil { + pl = string(payload) + } + stmt := table.PaymentEvents.INSERT( + table.PaymentEvents.EventID, table.PaymentEvents.AccountID, table.PaymentEvents.OrderID, + table.PaymentEvents.Type, table.PaymentEvents.Payload, table.PaymentEvents.CreatedAt, + ).VALUES(id, accountID, uuidOrNull(orderID), eventType, pl, now) + if _, err := stmt.ExecContext(ctx, s.db); err != nil { + return fmt.Errorf("payments: insert %s event: %w", eventType, err) + } + return nil +} + // orderTTL reads the configured pending-order lifetime in whole seconds. func (s *Store) orderTTL(ctx context.Context) (int, error) { var cfg model.Config diff --git a/backend/internal/server/handlers.go b/backend/internal/server/handlers.go index c704f00..6f9a721 100644 --- a/backend/internal/server/handlers.go +++ b/backend/internal/server/handlers.go @@ -73,6 +73,12 @@ func (s *Server) registerRoutes() { u.GET("/wallet/catalog", s.handleWalletCatalog) u.POST("/wallet/buy", s.handleWalletBuy) } + if s.payments != nil && s.robokassa.MerchantLogin != "" { + // Direct-rail (Robokassa): open a pending order (user group), and receive the verified + // Result callback the gateway forwards (internal group, gateway-only — the single writer). + u.POST("/wallet/order", s.handleWalletOrder) + s.internal.POST("/payments/robokassa/result", s.handleRobokassaResult) + } if s.links != nil { // Account linking & merge. The request step always mails a code; // a required merge is revealed only after the code is verified, and the @@ -266,6 +272,8 @@ func statusForError(err error) (int, string) { return http.StatusNotFound, "product_not_found" case errors.Is(err, payments.ErrNotAValue): return http.StatusBadRequest, "not_a_value" + case errors.Is(err, payments.ErrNotAPack): + return http.StatusBadRequest, "not_a_pack" case errors.Is(err, account.ErrInvalidEmail): return http.StatusBadRequest, "invalid_email" case errors.Is(err, account.ErrCodeMismatch), errors.Is(err, account.ErrCodeExpired), diff --git a/backend/internal/server/handlers_intake.go b/backend/internal/server/handlers_intake.go new file mode 100644 index 0000000..de19c44 --- /dev/null +++ b/backend/internal/server/handlers_intake.go @@ -0,0 +1,125 @@ +package server + +import ( + "encoding/json" + "errors" + "net/http" + "net/url" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "go.uber.org/zap" + + "scrabble/backend/internal/payments" +) + +// providerRobokassa is the ledger/order provider tag for the direct (RUB) rail. +const providerRobokassa = "robokassa" + +// 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 provider launch URL the client opens. +type walletOrderResponse struct { + OrderID string `json:"order_id"` + RedirectURL string `json:"redirect_url"` +} + +// handleWalletOrder opens a pending order to fund a chip pack and returns the Robokassa +// hosted-payment URL for the client to open. It is direct-rail only for now (VK/TG land later); +// it enforces the wallet gate and D36 (a direct purchase requires a confirmed email anchor). No +// chips are credited here — only later, by the verified Result callback. +func (s *Server) handleWalletOrder(c *gin.Context) { + uid, ok := userID(c) + if !ok { + return + } + var req walletOrderRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, errorResponse{Error: errorBody{Code: "invalid_request", Message: "invalid request body"}}) + return + } + productID, err := uuid.Parse(req.ProductID) + if err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, errorResponse{Error: errorBody{Code: "invalid_request", Message: "invalid product id"}}) + return + } + ctx := c.Request.Context() + cxt, present, err := s.walletGate(ctx, uid) + if err != nil { + s.abortErr(c, err) + return + } + if cxt.Kind != payments.SourceDirect { + c.AbortWithStatusJSON(http.StatusNotImplemented, errorResponse{Error: errorBody{Code: "rail_unavailable", Message: "this payment method is not available yet"}}) + return + } + hasEmail, err := s.accounts.HasConfirmedEmail(ctx, uid) + if err != nil { + s.abortErr(c, err) + return + } + if !hasEmail { + c.AbortWithStatusJSON(http.StatusForbidden, errorResponse{Error: errorBody{Code: "email_required", Message: "confirm your email before making a purchase"}}) + return + } + res, err := s.payments.CreateOrder(ctx, uid, cxt, present, productID, providerRobokassa) + if err != nil { + s.abortErr(c, err) + return + } + c.JSON(http.StatusOK, walletOrderResponse{ + OrderID: res.OrderID.String(), + RedirectURL: s.robokassa.PaymentURL(res.OrderID, res.Amount.Major(), res.Title), + }) +} + +// handleRobokassaResult is the verified Robokassa Result callback, reached only through the gateway +// (which forwards the provider's form parameters as a JSON object on the internal, gateway-only +// route). It verifies the Password2 signature, credits the matched order exactly once (idempotent, +// and honoured even if the order expired), records a succeeded event, and answers Robokassa's +// expected "OK". A duplicate callback credits nothing but still answers OK. +func (s *Server) handleRobokassaResult(c *gin.Context) { + var params map[string]string + if err := c.ShouldBindJSON(¶ms); err != nil { + c.String(http.StatusBadRequest, "bad request") + return + } + v := url.Values{} + for k, val := range params { + v.Set(k, val) + } + orderID, outSum, ok := s.robokassa.VerifyResult(v) + if !ok { + s.log.Warn("robokassa result: bad signature") + c.String(http.StatusBadRequest, "bad sign") + return + } + paid, err := payments.ParseMoney(outSum, payments.CurrencyRUB) + if err != nil { + c.String(http.StatusBadRequest, "bad amount") + return + } + ctx := c.Request.Context() + outcome, err := s.payments.Fund(ctx, orderID, providerRobokassa, orderID.String(), paid) + if err != nil { + if errors.Is(err, payments.ErrOrderNotFound) || errors.Is(err, payments.ErrAmountMismatch) || + errors.Is(err, payments.ErrNotAPack) || errors.Is(err, payments.ErrProductNotFound) { + s.log.Warn("robokassa result rejected", zap.String("order", orderID.String()), zap.Error(err)) + c.String(http.StatusBadRequest, "rejected") + return + } + s.log.Error("robokassa fund failed", zap.String("order", orderID.String()), zap.Error(err)) + c.String(http.StatusInternalServerError, "error") + return + } + if !outcome.AlreadyCredited { + 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 { + s.log.Error("record payment event failed", zap.String("order", orderID.String()), zap.Error(err)) + } + } + c.String(http.StatusOK, "OK"+v.Get("InvId")) +} diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index 9e61c48..339d735 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -31,6 +31,7 @@ import ( "scrabble/backend/internal/payments" "scrabble/backend/internal/ratewatch" "scrabble/backend/internal/render" + "scrabble/backend/internal/robokassa" "scrabble/backend/internal/session" "scrabble/backend/internal/social" "scrabble/backend/internal/telemetry" @@ -109,6 +110,9 @@ 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; an empty MerchantLogin leaves the + // order and Result-callback endpoints unregistered. + Robokassa robokassa.Config } // Server owns the gin engine, the underlying HTTP server and the readiness @@ -136,6 +140,7 @@ type Server struct { banview *banview.View ads *ads.Service payments *payments.Service + robokassa robokassa.Config notifier notify.Publisher console *adminconsole.Renderer exportKey []byte @@ -189,6 +194,7 @@ func New(addr string, deps Deps) *Server { banview: deps.BanView, ads: deps.Ads, payments: deps.Payments, + robokassa: deps.Robokassa, notifier: notifier, renderer: deps.Renderer, http: &http.Server{Addr: addr, Handler: engine},