From 7860efce482c7b69843895bf20e01bf7ef5349f6 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Thu, 9 Jul 2026 16:48:48 +0200 Subject: [PATCH 1/9] feat(payments): order-flow and fund credit engine + the Robokassa adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the payment-intake write path (provider-agnostic) and the Robokassa direct-rail glue, both unit-tested; transport, wire and UI follow. - payments: extend the ledger insert to thread order_id/provider/ provider_payment_id (spend/grant pass nil); add the order store (create/read/expire + a pack-price loader) and the fund credit — a fund ledger row + a guarded balance upsert + mark-paid in one tx, idempotent on the (provider, provider_payment_id) unique index, cache invalidated after commit. A valid callback is honoured even on an expired order. Service CreateOrder/Fund/ExpireOrders; Money.Major for the provider amount field. - robokassa: build the signed hosted-payment URL (SHA-256, order id via Shp_order, InvId unused) and verify the Result callback signature (Password2), extracting the order and amount. Receipt/fiscalisation is configured shop-side, so no Receipt parameter is sent. --- backend/internal/payments/payments.go | 16 +- backend/internal/payments/service_intake.go | 73 ++++ .../internal/payments/service_intake_test.go | 42 +++ backend/internal/payments/store_intake.go | 324 ++++++++++++++++++ backend/internal/payments/store_wallet.go | 25 +- backend/internal/robokassa/robokassa.go | 104 ++++++ backend/internal/robokassa/robokassa_test.go | 85 +++++ 7 files changed, 658 insertions(+), 11 deletions(-) create mode 100644 backend/internal/payments/service_intake.go create mode 100644 backend/internal/payments/service_intake_test.go create mode 100644 backend/internal/payments/store_intake.go create mode 100644 backend/internal/robokassa/robokassa.go create mode 100644 backend/internal/robokassa/robokassa_test.go diff --git a/backend/internal/payments/payments.go b/backend/internal/payments/payments.go index 33e4a25..f53dff4 100644 --- a/backend/internal/payments/payments.go +++ b/backend/internal/payments/payments.go @@ -147,12 +147,13 @@ func (m Money) Cmp(o Money) (int, error) { } } -// String renders the amount as " ", with the currency's -// fractional digits and no floating point (e.g. "149.50 RUB", "250 XTR"). -func (m Money) String() string { +// Major renders the amount as a decimal string without the currency, with the currency's +// fractional digits and no floating point (e.g. "149.50", "250") — the form a provider's amount +// field (Robokassa OutSum) takes. +func (m Money) Major() string { scale := m.currency.minorPerUnit() if scale == 1 { - return fmt.Sprintf("%d %s", m.minor, m.currency) + return fmt.Sprintf("%d", m.minor) } neg := m.minor < 0 abs := m.minor @@ -167,5 +168,10 @@ func (m Money) String() string { if neg { sign = "-" } - return fmt.Sprintf("%s%d.%0*d %s", sign, abs/scale, width, abs%scale, m.currency) + return fmt.Sprintf("%s%d.%0*d", sign, abs/scale, width, abs%scale) +} + +// String renders the amount as " " (e.g. "149.50 RUB", "250 XTR"). +func (m Money) String() string { + return m.Major() + " " + string(m.currency) } diff --git a/backend/internal/payments/service_intake.go b/backend/internal/payments/service_intake.go new file mode 100644 index 0000000..03dcd33 --- /dev/null +++ b/backend/internal/payments/service_intake.go @@ -0,0 +1,73 @@ +package payments + +import ( + "context" + "fmt" + + "github.com/google/uuid" +) + +// OrderResult is what CreateOrder returns to the transport: the created order id and the details a +// provider launch payload needs — the amount to charge and a human title for the payment. +type OrderResult struct { + OrderID uuid.UUID + Amount Money + Title string +} + +// CreateOrder opens a pending order to fund a chip pack in the execution context's payment method, +// tagged with the provider that will settle it. It gate-checks the context (trusted, not the +// VK-iOS spend freeze) and that the method's funding segment is attached, prices the pack in the +// method's currency, then writes the order. The caller enforces any account-level precondition +// (e.g. the direct email anchor, D36) before calling — payments holds no cross-schema identity +// knowledge. +func (s *Service) CreateOrder(ctx context.Context, accountID uuid.UUID, cxt Context, present []Source, productID uuid.UUID, provider string) (OrderResult, error) { + if !cxt.Trusted() || cxt.vkFrozen() { + return OrderResult{}, ErrUntrusted + } + method := cxt.Kind + if !has(present, method) { + return OrderResult{}, ErrUntrusted // the funding segment is not attached to the account + } + pack, err := s.store.loadPackForOrder(ctx, productID, method) + if err != nil { + return OrderResult{}, err + } + orderID, err := uuid.NewV7() + if err != nil { + return OrderResult{}, fmt.Errorf("payments: order id: %w", err) + } + o := newOrder{ + orderID: orderID, + accountID: accountID, + platform: string(method), + productID: productID, + amount: pack.price, + origin: method, + provider: provider, + } + if err := s.store.createOrder(ctx, o, s.clock()); err != nil { + return OrderResult{}, err + } + return OrderResult{OrderID: orderID, Amount: pack.price, Title: pack.title}, nil +} + +// Fund credits a paid order into its funded segment exactly once, from a verified provider callback +// — the single writer for every rail. It matches the order, verifies the paid amount, appends the +// fund ledger row (idempotent on (provider, provider_payment_id)), credits the balance and marks +// the order paid. A duplicate callback returns AlreadyCredited without a second credit; a valid +// callback is honoured even on an expired order (§9/D23). +func (s *Service) Fund(ctx context.Context, orderID uuid.UUID, provider, providerPaymentID string, paid Money) (FundOutcome, error) { + return s.store.fund(ctx, orderID, provider, providerPaymentID, paid, s.clock()) +} + +// ExpireOrders marks pending orders older than the configured lifetime as expired, returning how +// many were swept. It backs the periodic pending reaper; expiry is cosmetic (a late valid callback +// still credits — see Fund). +func (s *Service) ExpireOrders(ctx context.Context) (int, error) { + ttl, err := s.store.orderTTL(ctx) + if err != nil { + return 0, err + } + return s.store.expirePending(ctx, ttl, s.clock()) +} diff --git a/backend/internal/payments/service_intake_test.go b/backend/internal/payments/service_intake_test.go new file mode 100644 index 0000000..11159b1 --- /dev/null +++ b/backend/internal/payments/service_intake_test.go @@ -0,0 +1,42 @@ +package payments + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/google/uuid" +) + +// gateOnlyService builds a Service with a fixed clock and no store, usable only for the CreateOrder +// gate rejections that return before any store access. +func gateOnlyService() *Service { + return &Service{clock: func() time.Time { return time.Unix(0, 0).UTC() }} +} + +// TestCreateOrderGateRejections checks that CreateOrder fails closed — before touching the store — +// on an untrusted platform, the VK-iOS spend freeze, and a method whose funding segment the account +// does not hold. +func TestCreateOrderGateRejections(t *testing.T) { + ctx := context.Background() + acc, prod := uuid.New(), uuid.New() + present := []Source{SourceDirect, SourceVK} + + cases := []struct { + name string + cxt Context + }{ + {"untrusted context", Context{}}, + {"vk-ios spend freeze", Context{Kind: SourceVK, Subtype: SubtypeIOS}}, + {"method segment not attached", Context{Kind: SourceTelegram}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := gateOnlyService().CreateOrder(ctx, acc, tc.cxt, present, prod, "robokassa") + if !errors.Is(err, ErrUntrusted) { + t.Fatalf("CreateOrder(%s) = %v, want ErrUntrusted", tc.name, err) + } + }) + } +} diff --git a/backend/internal/payments/store_intake.go b/backend/internal/payments/store_intake.go new file mode 100644 index 0000000..9bc8796 --- /dev/null +++ b/backend/internal/payments/store_intake.go @@ -0,0 +1,324 @@ +package payments + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/go-jet/jet/v2/postgres" + "github.com/go-jet/jet/v2/qrm" + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgconn" + + "scrabble/backend/internal/postgres/jet/payments/model" + "scrabble/backend/internal/postgres/jet/payments/table" +) + +// Intake errors surfaced by the order-flow and external-credit (fund) path. +var ( + // ErrNotAPack means the product is not a fundable chip pack in the requested method: it + // carries no chips atom, or no price for that payment method. + ErrNotAPack = errors.New("payments: product is not a chip pack for this method") + // ErrOrderNotFound means no order matches the id (an unknown or forged callback reference). + ErrOrderNotFound = errors.New("payments: order not found") + // ErrAmountMismatch means the callback's paid amount or currency does not match the order's + // expected amount — the credit is refused (§9: verify amount after matching by order id). + ErrAmountMismatch = errors.New("payments: paid amount does not match the order") +) + +// errAlreadyCredited is the internal sentinel that unwinds the fund transaction when the ledger's +// (provider, provider_payment_id) unique index rejects a duplicate callback. It is not surfaced: +// a replayed callback is a success that credits nothing. +var errAlreadyCredited = errors.New("payments: already credited") + +// packInfo is a chip pack resolved for an order: the product, the chips it funds and its price in +// the requested payment method's currency. +type packInfo struct { + productID uuid.UUID + title string + chips int + price Money +} + +// packChips returns the quantity of the chips atom a product carries, or 0 if it has none (which +// marks it as a value, not a fundable pack). +func (s *Store) packChips(ctx context.Context, productID uuid.UUID) (int, error) { + var item model.ProductItem + err := postgres.SELECT(table.ProductItem.AllColumns). + FROM(table.ProductItem). + WHERE(table.ProductItem.ProductID.EQ(postgres.UUID(productID)). + AND(table.ProductItem.AtomType.EQ(postgres.String(atomChips)))). + LIMIT(1). + QueryContext(ctx, s.db, &item) + if errors.Is(err, qrm.ErrNoRows) { + return 0, nil + } + if err != nil { + return 0, fmt.Errorf("payments: load pack chips %s: %w", productID, err) + } + return int(item.Quantity), nil +} + +// loadPackForOrder resolves an active chip pack for a new order in the given payment method: an +// active product carrying a chips atom and a price row for the method (its currency and amount are +// the order's expected amount). It rejects a missing/deactivated product (ErrProductNotFound) and +// a product that is not a pack for the method (ErrNotAPack). +func (s *Store) loadPackForOrder(ctx context.Context, productID uuid.UUID, method Source) (packInfo, error) { + var p model.Product + err := postgres.SELECT(table.Product.AllColumns). + FROM(table.Product). + WHERE(table.Product.ProductID.EQ(postgres.UUID(productID))). + LIMIT(1). + QueryContext(ctx, s.db, &p) + if errors.Is(err, qrm.ErrNoRows) || (err == nil && !p.Active) { + return packInfo{}, ErrProductNotFound + } + if err != nil { + return packInfo{}, fmt.Errorf("payments: load product %s: %w", productID, err) + } + + chips, err := s.packChips(ctx, productID) + if err != nil { + return packInfo{}, err + } + if chips <= 0 { + return packInfo{}, ErrNotAPack + } + + var price model.ProductPrice + err = postgres.SELECT(table.ProductPrice.AllColumns). + FROM(table.ProductPrice). + WHERE(table.ProductPrice.ProductID.EQ(postgres.UUID(productID)). + AND(table.ProductPrice.Method.EQ(postgres.String(string(method))))). + LIMIT(1). + QueryContext(ctx, s.db, &price) + if errors.Is(err, qrm.ErrNoRows) { + return packInfo{}, ErrNotAPack + } + if err != nil { + return packInfo{}, fmt.Errorf("payments: load pack price %s: %w", productID, err) + } + money, err := MoneyFromMinor(price.Amount, Currency(price.Currency)) + if err != nil { + return packInfo{}, err + } + return packInfo{productID: productID, title: p.Title, chips: chips, price: money}, nil +} + +// packForCredit resolves the chips and title of an ordered pack at credit time, ignoring the +// product's active flag: the money is real, so an order is honoured even if the pack was +// deactivated after it was placed (§9/D23). +func (s *Store) packForCredit(ctx context.Context, productID uuid.UUID) (chips int, title string, err error) { + var p model.Product + e := postgres.SELECT(table.Product.Title). + FROM(table.Product). + WHERE(table.Product.ProductID.EQ(postgres.UUID(productID))). + LIMIT(1). + QueryContext(ctx, s.db, &p) + if errors.Is(e, qrm.ErrNoRows) { + return 0, "", ErrProductNotFound + } + if e != nil { + return 0, "", fmt.Errorf("payments: load product %s: %w", productID, e) + } + chips, err = s.packChips(ctx, productID) + if err != nil { + return 0, "", err + } + if chips <= 0 { + return 0, "", ErrNotAPack + } + return chips, p.Title, nil +} + +// newOrder is the intent a CreateOrder writes: a pending order for a pack, priced in the method's +// currency, tagged with the provider that will settle it. +type newOrder struct { + orderID uuid.UUID + accountID uuid.UUID + platform string + productID uuid.UUID + amount Money + origin Source + provider string +} + +// createOrder inserts a pending order. +func (s *Store) createOrder(ctx context.Context, o newOrder, now time.Time) error { + stmt := table.Orders.INSERT( + table.Orders.OrderID, table.Orders.AccountID, table.Orders.Platform, + table.Orders.ProductID, table.Orders.ExpectedAmount, table.Orders.Currency, + table.Orders.Origin, table.Orders.Status, table.Orders.Provider, + table.Orders.CreatedAt, table.Orders.UpdatedAt, + ).VALUES( + o.orderID, o.accountID, o.platform, + o.productID, o.amount.Minor(), string(o.amount.Currency()), + string(o.origin), "pending", o.provider, + now, now, + ) + if _, err := stmt.ExecContext(ctx, s.db); err != nil { + return fmt.Errorf("payments: create order: %w", err) + } + return nil +} + +// orderRow is a stored order read back for the intake path. +type orderRow struct { + orderID uuid.UUID + accountID uuid.UUID + productID uuid.UUID + expectedAmount int64 + currency string + origin string + status string +} + +// orderByID reads an order, or ErrOrderNotFound. +func (s *Store) orderByID(ctx context.Context, orderID uuid.UUID) (orderRow, error) { + var o model.Orders + err := postgres.SELECT(table.Orders.AllColumns). + FROM(table.Orders). + WHERE(table.Orders.OrderID.EQ(postgres.UUID(orderID))). + LIMIT(1). + QueryContext(ctx, s.db, &o) + if errors.Is(err, qrm.ErrNoRows) { + return orderRow{}, ErrOrderNotFound + } + if err != nil { + return orderRow{}, fmt.Errorf("payments: load order %s: %w", orderID, err) + } + return orderRow{ + orderID: o.OrderID, + accountID: o.AccountID, + productID: o.ProductID, + expectedAmount: o.ExpectedAmount, + currency: o.Currency, + origin: o.Origin, + status: o.Status, + }, nil +} + +// FundOutcome reports the result of an intake credit: whose balance, which segment and how many +// chips were credited, and whether the callback was a duplicate that credited nothing. +type FundOutcome struct { + AccountID uuid.UUID + Source Source + Chips int + AlreadyCredited bool +} + +// fund credits a paid order exactly once: it matches the order, verifies the amount, then in one +// transaction appends a fund ledger row (idempotent on the (provider, provider_payment_id) unique +// index), credits the funded segment's balance and marks the order paid. A duplicate callback is +// rejected by the unique index and returns AlreadyCredited with no error and no second credit. A +// valid callback is honoured even on an expired order (§9/D23). The read cache is invalidated after +// the commit, since the credit runs outside any request the payments package owns. +func (s *Store) fund(ctx context.Context, orderID uuid.UUID, provider, providerPaymentID string, paid Money, now time.Time) (FundOutcome, error) { + ord, err := s.orderByID(ctx, orderID) + if err != nil { + return FundOutcome{}, err + } + if paid.Currency() != Currency(ord.currency) || paid.Minor() != ord.expectedAmount { + return FundOutcome{}, ErrAmountMismatch + } + chips, title, err := s.packForCredit(ctx, ord.productID) + if err != nil { + return FundOutcome{}, err + } + snapshot, err := marshalFundSnapshot(ord.productID, title, chips, paid) + if err != nil { + return FundOutcome{}, err + } + + src := Source(ord.origin) + outcome := FundOutcome{AccountID: ord.accountID, Source: src, Chips: chips} + pv, pp := provider, providerPaymentID + productID := ord.productID + err = withTx(ctx, s.db, func(tx *sql.Tx) error { + if e := insertLedgerTx(ctx, tx, ord.accountID, "fund", &src, &src, chips, &productID, &orderID, &pv, &pp, snapshot, now); e != nil { + if isUniqueViolation(e) { + outcome.AlreadyCredited = true + return errAlreadyCredited + } + return e + } + if _, e := tx.ExecContext(ctx, + `INSERT INTO payments.balances (account_id, source, chips, updated_at) + VALUES ($1, $2, $3, now()) + ON CONFLICT (account_id, source) DO UPDATE + SET chips = payments.balances.chips + EXCLUDED.chips, updated_at = now()`, + ord.accountID, string(src), chips); e != nil { + return fmt.Errorf("payments: credit balance %s: %w", src, e) + } + if _, e := tx.ExecContext(ctx, + `UPDATE payments.orders SET status = 'paid', provider = $2, provider_payment_id = $3, updated_at = now() + WHERE order_id = $1`, + orderID, provider, providerPaymentID); e != nil { + return fmt.Errorf("payments: mark order paid: %w", e) + } + return nil + }) + if err != nil { + if errors.Is(err, errAlreadyCredited) { + return outcome, nil + } + return FundOutcome{}, err + } + s.cache.invalidate(ord.accountID) + return outcome, nil +} + +// orderTTL reads the configured pending-order lifetime in whole seconds. +func (s *Store) orderTTL(ctx context.Context) (int, error) { + var cfg model.Config + if err := postgres.SELECT(table.Config.OrderTTLSeconds). + FROM(table.Config). + LIMIT(1). + QueryContext(ctx, s.db, &cfg); err != nil { + return 0, fmt.Errorf("payments: read order ttl: %w", err) + } + return int(cfg.OrderTTLSeconds), nil +} + +// expirePending marks every pending order older than ttlSeconds as expired, returning how many. +// Expiry is cosmetic DB hygiene: a later valid callback still credits an expired order (§9/D23). +func (s *Store) expirePending(ctx context.Context, ttlSeconds int, now time.Time) (int, error) { + cutoff := now.Add(-time.Duration(ttlSeconds) * time.Second) + res, err := table.Orders. + UPDATE(table.Orders.Status, table.Orders.UpdatedAt). + SET(postgres.String("expired"), postgres.TimestampzT(now)). + WHERE(table.Orders.Status.EQ(postgres.String("pending")). + AND(table.Orders.CreatedAt.LT(postgres.TimestampzT(cutoff)))). + ExecContext(ctx, s.db) + if err != nil { + return 0, fmt.Errorf("payments: expire pending orders: %w", err) + } + n, _ := res.RowsAffected() + return int(n), nil +} + +// marshalFundSnapshot records what a fund credited (the pack, chips and paid amount) on the ledger +// row, so history stays independent of later catalog edits (§7/D34). +func marshalFundSnapshot(productID uuid.UUID, title string, chips int, paid Money) ([]byte, error) { + b, err := json.Marshal(struct { + ProductID string `json:"product_id"` + Title string `json:"title,omitempty"` + Chips int `json:"chips"` + Amount int64 `json:"amount_minor"` + Currency string `json:"currency"` + }{productID.String(), title, chips, paid.Minor(), string(paid.Currency())}) + if err != nil { + return nil, fmt.Errorf("payments: marshal fund snapshot: %w", err) + } + return b, nil +} + +// isUniqueViolation reports whether err is a PostgreSQL unique-constraint violation (SQLSTATE +// 23505) — here, a duplicate provider callback hitting the ledger idempotency index. +func isUniqueViolation(err error) bool { + var pgErr *pgconn.PgError + return errors.As(err, &pgErr) && pgErr.Code == "23505" +} diff --git a/backend/internal/payments/store_wallet.go b/backend/internal/payments/store_wallet.go index 825f1f2..59cff1b 100644 --- a/backend/internal/payments/store_wallet.go +++ b/backend/internal/payments/store_wallet.go @@ -230,8 +230,11 @@ func applyBenefitTx(ctx context.Context, tx *sql.Tx, accountID uuid.UUID, origin return nil } -// insertLedgerTx appends one append-only ledger row inside tx. -func insertLedgerTx(ctx context.Context, tx *sql.Tx, accountID uuid.UUID, kind string, source, origin *Source, chipsDelta int, productID *uuid.UUID, snapshot []byte, now time.Time) error { +// insertLedgerTx appends one append-only ledger row inside tx. orderID, provider and +// providerPaymentID are set only on an intake credit (fund/refund) and are nil for a +// spend/admin_grant; a non-nil (provider, providerPaymentID) pair is guarded by the partial +// unique index, so a duplicate provider callback fails here (the idempotency key). +func insertLedgerTx(ctx context.Context, tx *sql.Tx, accountID uuid.UUID, kind string, source, origin *Source, chipsDelta int, productID, orderID *uuid.UUID, provider, providerPaymentID *string, snapshot []byte, now time.Time) error { id, err := uuid.NewV7() if err != nil { return fmt.Errorf("payments: ledger id: %w", err) @@ -246,11 +249,13 @@ func insertLedgerTx(ctx context.Context, tx *sql.Tx, accountID uuid.UUID, kind s stmt := table.Ledger.INSERT( table.Ledger.LedgerID, table.Ledger.AccountID, table.Ledger.Kind, table.Ledger.Source, table.Ledger.Origin, table.Ledger.ChipsDelta, - table.Ledger.ProductID, table.Ledger.Snapshot, table.Ledger.CreatedAt, + table.Ledger.ProductID, table.Ledger.OrderID, table.Ledger.Provider, + table.Ledger.ProviderPaymentID, table.Ledger.Snapshot, table.Ledger.CreatedAt, ).VALUES( id, accountID, kind, sourceOrNull(source), sourceOrNull(origin), int32(chipsDelta), - uuidOrNull(productID), snap, now, + uuidOrNull(productID), uuidOrNull(orderID), stringOrNull(provider), + stringOrNull(providerPaymentID), snap, now, ) if _, err := stmt.ExecContext(ctx, tx); err != nil { return fmt.Errorf("payments: insert %s ledger: %w", kind, err) @@ -278,7 +283,7 @@ func (s *Store) spend(ctx context.Context, accountID uuid.UUID, draws []sourceAm return ErrInsufficientChips } src := dr.source - if err := insertLedgerTx(ctx, tx, accountID, "spend", &src, &origin, -dr.amount, &productID, snapshot, now); err != nil { + if err := insertLedgerTx(ctx, tx, accountID, "spend", &src, &origin, -dr.amount, &productID, nil, nil, nil, snapshot, now); err != nil { return err } } @@ -295,7 +300,7 @@ func (s *Store) spend(ctx context.Context, accountID uuid.UUID, draws []sourceAm // the chosen origin, in one transaction — a zero-price sale of a value. func (s *Store) grant(ctx context.Context, accountID uuid.UUID, origin Source, d benefitDelta, snapshot []byte, now time.Time) error { err := withTx(ctx, s.db, func(tx *sql.Tx) error { - if err := insertLedgerTx(ctx, tx, accountID, "admin_grant", nil, &origin, 0, nil, snapshot, now); err != nil { + if err := insertLedgerTx(ctx, tx, accountID, "admin_grant", nil, &origin, 0, nil, nil, nil, nil, snapshot, now); err != nil { return err } return applyBenefitTx(ctx, tx, accountID, origin, d, now) @@ -419,3 +424,11 @@ func uuidOrNull(id *uuid.UUID) postgres.Expression { } return postgres.UUID(*id) } + +// stringOrNull renders an optional string as a SQL string or NULL. +func stringOrNull(s *string) postgres.Expression { + if s == nil { + return postgres.NULL + } + return postgres.String(*s) +} diff --git a/backend/internal/robokassa/robokassa.go b/backend/internal/robokassa/robokassa.go new file mode 100644 index 0000000..d680255 --- /dev/null +++ b/backend/internal/robokassa/robokassa.go @@ -0,0 +1,104 @@ +// Package robokassa builds and verifies Robokassa direct-rail (RUB) payments: it forms the signed +// hosted-payment URL a client is sent to, and verifies the Result-URL server callback that credits +// an order. It is pure provider glue — no database, no payments-domain coupling — so the payments +// domain stays provider-agnostic and this layer is unit-testable in isolation. +// +// The order is threaded through Robokassa's custom-parameter channel as Shp_order= (echoed +// back in the callback and bound into the signature), not the numeric InvId, because an order id is +// a uuid; InvId is sent as 0. Idempotency is therefore keyed on the order id at the credit site. +// Signatures use SHA-256 (configured to match the shop's technical settings); the shop's test mode +// is carried by IsTest. +package robokassa + +import ( + "crypto/sha256" + "encoding/hex" + "net/url" + "sort" + "strings" + + "github.com/google/uuid" +) + +// payEndpoint is Robokassa's hosted payment page; the signed query sends the client there. +const payEndpoint = "https://auth.robokassa.ru/Merchant/Index.aspx" + +// Config is a Robokassa shop's credentials. Password1 signs the outgoing payment request; +// Password2 signs (and so verifies) the incoming Result callback. IsTest adds IsTest=1 so the shop's +// test mode simulates payments without money movement. +type Config struct { + MerchantLogin string + Password1 string + Password2 string + IsTest bool +} + +// PaymentURL builds the signed hosted-payment URL for an order: amount is the OutSum decimal string +// (roubles, e.g. "149.00"), description is the human payment purpose. The order id rides as +// Shp_order and is bound into the SHA-256 signature; InvId is 0 (unused). +func (c Config) PaymentURL(orderID uuid.UUID, amount, description string) string { + q := url.Values{} + q.Set("Shp_order", orderID.String()) + // Signature base: MerchantLogin:OutSum:InvId:Password1[:Shp_key=value...sorted]. + base := c.MerchantLogin + ":" + amount + ":0:" + c.Password1 + shpSuffix(q) + + q.Set("MerchantLogin", c.MerchantLogin) + q.Set("OutSum", amount) + q.Set("InvId", "0") + q.Set("Description", description) + q.Set("SignatureValue", sign(base)) + if c.IsTest { + q.Set("IsTest", "1") + } + return payEndpoint + "?" + q.Encode() +} + +// VerifyResult verifies a Result-URL callback and extracts the order it credits. It recomputes the +// SHA-256 signature OutSum:InvId:Password2[:Shp_...sorted] over the callback's own fields and +// compares it (case-insensitively) with SignatureValue. On success it returns the order id (from +// Shp_order) and the raw OutSum string the caller re-checks against the order amount; on any +// missing field, a signature mismatch or an unparseable order id it returns ok=false. +func (c Config) VerifyResult(v url.Values) (orderID uuid.UUID, outSum string, ok bool) { + outSum = v.Get("OutSum") + sig := v.Get("SignatureValue") + order := v.Get("Shp_order") + if outSum == "" || sig == "" || order == "" { + return uuid.Nil, "", false + } + // Robokassa signs with the InvId it returns in the callback; recompute with that same value. + base := outSum + ":" + v.Get("InvId") + ":" + c.Password2 + shpSuffix(v) + if !strings.EqualFold(sign(base), sig) { + return uuid.Nil, "", false + } + id, err := uuid.Parse(order) + if err != nil { + return uuid.Nil, "", false + } + return id, outSum, true +} + +// shpSuffix renders the Shp_ custom parameters of v as Robokassa binds them into a signature: +// every Shp_-prefixed key, sorted alphabetically, appended as ":key=value". +func shpSuffix(v url.Values) string { + var keys []string + for k := range v { + if strings.HasPrefix(k, "Shp_") { + keys = append(keys, k) + } + } + sort.Strings(keys) + var b strings.Builder + for _, k := range keys { + b.WriteString(":") + b.WriteString(k) + b.WriteString("=") + b.WriteString(v.Get(k)) + } + return b.String() +} + +// sign returns the lowercase hex SHA-256 of s, the hash Robokassa compares against SignatureValue. +func sign(s string) string { + sum := sha256.Sum256([]byte(s)) + return hex.EncodeToString(sum[:]) +} diff --git a/backend/internal/robokassa/robokassa_test.go b/backend/internal/robokassa/robokassa_test.go new file mode 100644 index 0000000..cc9f29f --- /dev/null +++ b/backend/internal/robokassa/robokassa_test.go @@ -0,0 +1,85 @@ +package robokassa + +import ( + "net/url" + "strings" + "testing" + + "github.com/google/uuid" +) + +func testCfg() Config { + return Config{MerchantLogin: "shop", Password1: "p1", Password2: "p2", IsTest: true} +} + +// resultSig computes the Pass2 Result signature Robokassa would send for a callback carrying only +// Shp_order — the fixture the verifier must accept. +func resultSig(pass2, outSum, invID string, orderID uuid.UUID) string { + return sign(outSum + ":" + invID + ":" + pass2 + ":Shp_order=" + orderID.String()) +} + +func TestPaymentURL(t *testing.T) { + cfg := testCfg() + id := uuid.New() + u, err := url.Parse(cfg.PaymentURL(id, "149.00", "10 chips")) + if err != nil { + t.Fatalf("parse payment url: %v", err) + } + q := u.Query() + if got := q.Get("OutSum"); got != "149.00" { + t.Errorf("OutSum = %q, want 149.00", got) + } + if got := q.Get("InvId"); got != "0" { + t.Errorf("InvId = %q, want 0 (unused)", got) + } + if got := q.Get("Shp_order"); got != id.String() { + t.Errorf("Shp_order = %q, want the order id", got) + } + if got := q.Get("IsTest"); got != "1" { + t.Errorf("IsTest = %q, want 1 (test shop)", got) + } + want := sign("shop:149.00:0:p1:Shp_order=" + id.String()) + if got := q.Get("SignatureValue"); got != want { + t.Errorf("SignatureValue = %q, want %q", got, want) + } +} + +func TestVerifyResult(t *testing.T) { + cfg := testCfg() + id := uuid.New() + + valid := func() url.Values { + v := url.Values{} + v.Set("OutSum", "149.00") + v.Set("InvId", "0") + v.Set("Shp_order", id.String()) + // Robokassa sends the hash uppercase; the verifier must be case-insensitive. + v.Set("SignatureValue", strings.ToUpper(resultSig("p2", "149.00", "0", id))) + return v + } + + gotID, gotSum, ok := cfg.VerifyResult(valid()) + if !ok || gotID != id || gotSum != "149.00" { + t.Fatalf("VerifyResult(valid) = %v/%q/%v, want %v/149.00/true", gotID, gotSum, ok, id) + } + + // A tampered amount breaks the signature. + tampered := valid() + tampered.Set("OutSum", "1.00") + if _, _, ok := cfg.VerifyResult(tampered); ok { + t.Error("VerifyResult accepted a tampered amount") + } + + // The wrong Password2 (a forged callback) does not verify. + wrongPass := Config{MerchantLogin: "shop", Password1: "p1", Password2: "other"} + if _, _, ok := wrongPass.VerifyResult(valid()); ok { + t.Error("VerifyResult accepted a signature under the wrong password") + } + + // A missing Shp_order is rejected (no order to credit). + noOrder := valid() + noOrder.Del("Shp_order") + if _, _, ok := cfg.VerifyResult(noOrder); ok { + t.Error("VerifyResult accepted a callback with no order") + } +} From 36a5730e52b8ffeef3f2b79f002e6ee8294089b6 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Thu, 9 Jul 2026 16:59:34 +0200 Subject: [PATCH 2/9] 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}, From a66a5bfa081c8be1675845c3e94429da874e0213 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Thu, 9 Jul 2026 17:04:14 +0200 Subject: [PATCH 3/9] test(payments): integration coverage for the intake credit path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cover the order→callback→credit path over Postgres: a funded order credits its segment exactly once; a replayed callback for the same order credits nothing (the ledger idempotency index holds); a mismatched paid amount is refused with no write; an expired pending order is still honoured by a late valid callback. --- .../internal/inttest/payments_intake_test.go | 142 ++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 backend/internal/inttest/payments_intake_test.go diff --git a/backend/internal/inttest/payments_intake_test.go b/backend/internal/inttest/payments_intake_test.go new file mode 100644 index 0000000..756d73c --- /dev/null +++ b/backend/internal/inttest/payments_intake_test.go @@ -0,0 +1,142 @@ +//go:build integration + +package inttest + +import ( + "context" + "errors" + "testing" + + "github.com/google/uuid" + + "scrabble/backend/internal/payments" +) + +// orderStatus reads an order's status. +func orderStatus(t *testing.T, orderID uuid.UUID) string { + t.Helper() + var status string + if err := testDB.QueryRowContext(context.Background(), + `SELECT status FROM payments.orders WHERE order_id=$1`, orderID).Scan(&status); err != nil { + t.Fatalf("read order status: %v", err) + } + return status +} + +// TestPaymentsOrderFundCreditsOnce verifies the intake path over Postgres: creating an order then +// funding it credits the funded segment exactly once, and a replayed callback (the same order) +// credits nothing more — the ledger idempotency index holds. +func TestPaymentsOrderFundCreditsOnce(t *testing.T) { + ctx := context.Background() + svc := newPaymentsService() + acc := uuid.New() + prod := seedPackProduct(t, 100, methodPrice{method: "direct", currency: "RUB", amount: 14900}) // 149.00 RUB funds 100 chips + + res, err := svc.CreateOrder(ctx, acc, payments.NewContext("direct", "web"), []payments.Source{payments.SourceDirect}, prod, "robokassa") + if err != nil { + t.Fatalf("create order: %v", err) + } + if res.Amount.Minor() != 14900 || res.Amount.Currency() != payments.CurrencyRUB { + t.Fatalf("order amount = %s, want 149.00 RUB", res.Amount) + } + if orderStatus(t, res.OrderID) != "pending" { + t.Errorf("new order status = %s, want pending", orderStatus(t, res.OrderID)) + } + + paid, _ := payments.MoneyFromMinor(14900, payments.CurrencyRUB) + out, err := svc.Fund(ctx, res.OrderID, "robokassa", res.OrderID.String(), paid) + if err != nil { + t.Fatalf("fund: %v", err) + } + if out.AlreadyCredited || out.Chips != 100 || out.Source != payments.SourceDirect { + t.Fatalf("fund outcome = %+v, want 100 chips to direct, not already-credited", out) + } + if got := readBalance(t, acc, "direct"); got != 100 { + t.Errorf("balance after fund = %d, want 100", got) + } + if ledgerRows(t, acc, "fund") != 1 { + t.Errorf("fund ledger rows = %d, want 1", ledgerRows(t, acc, "fund")) + } + if orderStatus(t, res.OrderID) != "paid" { + t.Errorf("order status after fund = %s, want paid", orderStatus(t, res.OrderID)) + } + + // A replayed callback for the same order is rejected by the unique index: no second credit. + out2, err := svc.Fund(ctx, res.OrderID, "robokassa", res.OrderID.String(), paid) + if err != nil { + t.Fatalf("duplicate fund: %v", err) + } + if !out2.AlreadyCredited { + t.Error("duplicate callback not flagged AlreadyCredited") + } + if got := readBalance(t, acc, "direct"); got != 100 { + t.Errorf("balance after duplicate = %d, want 100 (credited once)", got) + } + if ledgerRows(t, acc, "fund") != 1 { + t.Error("duplicate callback wrote a second fund ledger row") + } +} + +// TestPaymentsFundAmountMismatch verifies a callback whose paid amount does not match the order is +// refused and credits nothing (§9: verify the amount after matching by order id). +func TestPaymentsFundAmountMismatch(t *testing.T) { + ctx := context.Background() + svc := newPaymentsService() + acc := uuid.New() + prod := seedPackProduct(t, 100, methodPrice{method: "direct", currency: "RUB", amount: 14900}) + + res, err := svc.CreateOrder(ctx, acc, payments.NewContext("direct", "web"), []payments.Source{payments.SourceDirect}, prod, "robokassa") + if err != nil { + t.Fatalf("create order: %v", err) + } + underpaid, _ := payments.MoneyFromMinor(100, payments.CurrencyRUB) + if _, err := svc.Fund(ctx, res.OrderID, "robokassa", res.OrderID.String(), underpaid); !errors.Is(err, payments.ErrAmountMismatch) { + t.Fatalf("fund = %v, want ErrAmountMismatch", err) + } + if got := readBalance(t, acc, "direct"); got != 0 { + t.Errorf("balance = %d, want 0 (nothing credited on mismatch)", got) + } + if ledgerRows(t, acc, "fund") != 0 { + t.Error("fund ledger row written on an amount mismatch") + } +} + +// TestPaymentsExpiredOrderStillCredits verifies an expired pending order is still honoured by a +// later valid callback (§9/D23: expiry is cosmetic, the money is real). +func TestPaymentsExpiredOrderStillCredits(t *testing.T) { + ctx := context.Background() + svc := newPaymentsService() + acc := uuid.New() + prod := seedPackProduct(t, 100, methodPrice{method: "direct", currency: "RUB", amount: 14900}) + + res, err := svc.CreateOrder(ctx, acc, payments.NewContext("direct", "web"), []payments.Source{payments.SourceDirect}, prod, "robokassa") + if err != nil { + t.Fatalf("create order: %v", err) + } + // Age the order well past the configured TTL, then sweep it to expired. + if _, err := testDB.ExecContext(ctx, + `UPDATE payments.orders SET created_at = now() - interval '1 day' WHERE order_id=$1`, res.OrderID); err != nil { + t.Fatalf("age order: %v", err) + } + if n, err := svc.ExpireOrders(ctx); err != nil || n < 1 { + t.Fatalf("expire orders = %d (err %v), want at least 1", n, err) + } + if orderStatus(t, res.OrderID) != "expired" { + t.Fatalf("order status = %s, want expired before the late callback", orderStatus(t, res.OrderID)) + } + + paid, _ := payments.MoneyFromMinor(14900, payments.CurrencyRUB) + out, err := svc.Fund(ctx, res.OrderID, "robokassa", res.OrderID.String(), paid) + if err != nil { + t.Fatalf("fund after expiry: %v", err) + } + if out.AlreadyCredited || out.Chips != 100 { + t.Fatalf("fund outcome = %+v, want a fresh 100-chip credit", out) + } + if got := readBalance(t, acc, "direct"); got != 100 { + t.Errorf("balance = %d, want 100 (expired order honoured)", got) + } + if orderStatus(t, res.OrderID) != "paid" { + t.Errorf("order status = %s, want paid after the honoured callback", orderStatus(t, res.OrderID)) + } +} From 2a6dc5a304f9a8a04d2bd585f792755c18d02d0e Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Thu, 9 Jul 2026 17:30:39 +0200 Subject: [PATCH 4/9] feat(gateway): wire the wallet.order edge call for the direct rail Add the WalletOrderRequest / WalletOrderResponse FlatBuffers messages and the wallet.order Connect op: the gateway decodes the order request, forwards it to the backend POST /wallet/order and returns the created order id plus the provider launch URL the client opens. Regenerate the committed Go and TS FlatBuffers code. --- gateway/internal/backendclient/api.go | 18 +++++ gateway/internal/transcode/encode.go | 13 ++++ gateway/internal/transcode/transcode.go | 13 ++++ pkg/fbs/scrabble.fbs | 12 ++++ pkg/fbs/scrabblefb/WalletOrderRequest.go | 60 ++++++++++++++++ pkg/fbs/scrabblefb/WalletOrderResponse.go | 71 +++++++++++++++++++ ui/src/gen/fbs/scrabblefb.ts | 2 + .../fbs/scrabblefb/wallet-order-request.ts | 48 +++++++++++++ .../fbs/scrabblefb/wallet-order-response.ts | 60 ++++++++++++++++ 9 files changed, 297 insertions(+) create mode 100644 pkg/fbs/scrabblefb/WalletOrderRequest.go create mode 100644 pkg/fbs/scrabblefb/WalletOrderResponse.go create mode 100644 ui/src/gen/fbs/scrabblefb/wallet-order-request.ts create mode 100644 ui/src/gen/fbs/scrabblefb/wallet-order-response.ts diff --git a/gateway/internal/backendclient/api.go b/gateway/internal/backendclient/api.go index 1607b67..09f3352 100644 --- a/gateway/internal/backendclient/api.go +++ b/gateway/internal/backendclient/api.go @@ -396,6 +396,24 @@ func (c *Client) WalletBuy(ctx context.Context, userID, productID string) (Walle return out, err } +// walletOrderBody is the POST body of an order: the chip pack to fund. +type walletOrderBody struct { + ProductID string `json:"product_id"` +} + +// WalletOrderResp is a created order: its id and the provider launch URL the client opens. +type WalletOrderResp struct { + OrderID string `json:"order_id"` + RedirectURL string `json:"redirect_url"` +} + +// WalletOrder opens a pending order to fund a chip pack and returns the provider launch URL. +func (c *Client) WalletOrder(ctx context.Context, userID, productID string) (WalletOrderResp, error) { + var out WalletOrderResp + err := c.do(ctx, http.MethodPost, "/api/v1/user/wallet/order", userID, "", walletOrderBody{ProductID: productID}, &out) + return out, err +} + // CatalogAtomResp is one atom line of a storefront product: the value type it grants and quantity. type CatalogAtomResp struct { AtomType string `json:"atom_type"` diff --git a/gateway/internal/transcode/encode.go b/gateway/internal/transcode/encode.go index b99c973..a836722 100644 --- a/gateway/internal/transcode/encode.go +++ b/gateway/internal/transcode/encode.go @@ -37,6 +37,19 @@ func encodeAck(ok bool) []byte { return b.FinishedBytes() } +// encodeWalletOrder builds a WalletOrderResponse payload: the created order id and the provider +// launch URL the client opens. +func encodeWalletOrder(o backendclient.WalletOrderResp) []byte { + b := flatbuffers.NewBuilder(128) + oid := b.CreateString(o.OrderID) + url := b.CreateString(o.RedirectURL) + fb.WalletOrderResponseStart(b) + fb.WalletOrderResponseAddOrderId(b, oid) + fb.WalletOrderResponseAddRedirectUrl(b, url) + b.Finish(fb.WalletOrderResponseEnd(b)) + return b.FinishedBytes() +} + // encodeDeleteRequestResult builds an AccountDeleteRequestResult payload reporting which // deletion step-up the account uses ("email" | "phrase"). func encodeDeleteRequestResult(method string) []byte { diff --git a/gateway/internal/transcode/transcode.go b/gateway/internal/transcode/transcode.go index 3b5c76f..0b6a399 100644 --- a/gateway/internal/transcode/transcode.go +++ b/gateway/internal/transcode/transcode.go @@ -55,6 +55,7 @@ const ( MsgWalletGet = "wallet.get" MsgWalletCatalog = "wallet.catalog" MsgWalletBuy = "wallet.buy" + MsgWalletOrder = "wallet.order" ) // Request is one decoded Execute call. @@ -111,6 +112,7 @@ func NewRegistry(backend *backendclient.Client, tg TelegramValidator, opts ...Op r.ops[MsgWalletGet] = Op{Handler: walletHandler(backend), Auth: true} r.ops[MsgWalletCatalog] = Op{Handler: walletCatalogHandler(backend), Auth: true} r.ops[MsgWalletBuy] = Op{Handler: walletBuyHandler(backend), Auth: true} + r.ops[MsgWalletOrder] = Op{Handler: walletOrderHandler(backend), Auth: true} r.ops[MsgBlockStatus] = Op{Handler: blockStatusHandler(backend), Auth: true} r.ops[MsgGameSubmitPlay] = Op{Handler: submitPlayHandler(backend), Auth: true} r.ops[MsgGameState] = Op{Handler: gameStateHandler(backend), Auth: true} @@ -331,6 +333,17 @@ func walletBuyHandler(backend *backendclient.Client) Handler { } } +func walletOrderHandler(backend *backendclient.Client) Handler { + return func(ctx context.Context, req Request) ([]byte, error) { + in := fb.GetRootAsWalletOrderRequest(req.Payload, 0) + o, err := backend.WalletOrder(ctx, req.UserID, string(in.ProductId())) + if err != nil { + return nil, err + } + return encodeWalletOrder(o), nil + } +} + func blockStatusHandler(backend *backendclient.Client) Handler { return func(ctx context.Context, req Request) ([]byte, error) { bs, err := backend.BlockStatus(ctx, req.UserID) diff --git a/pkg/fbs/scrabble.fbs b/pkg/fbs/scrabble.fbs index 89e13f5..196d2ce 100644 --- a/pkg/fbs/scrabble.fbs +++ b/pkg/fbs/scrabble.fbs @@ -873,6 +873,18 @@ table WalletBuyRequest { product_id:string; } +// WalletOrderRequest opens a money order to fund a chip pack: the pack to buy. +table WalletOrderRequest { + product_id:string; +} + +// WalletOrderResponse returns the created order id and the provider launch URL the client opens +// (the Robokassa hosted-payment page); chips are credited later, by the verified server callback. +table WalletOrderResponse { + order_id:string; + redirect_url:string; +} + // CatalogAtom is one atom line of a storefront product: the base value type it grants // ("chips"/"hints"/"noads_days"/"tournament") and how many of it the product carries. table CatalogAtom { diff --git a/pkg/fbs/scrabblefb/WalletOrderRequest.go b/pkg/fbs/scrabblefb/WalletOrderRequest.go new file mode 100644 index 0000000..a19177d --- /dev/null +++ b/pkg/fbs/scrabblefb/WalletOrderRequest.go @@ -0,0 +1,60 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package scrabblefb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type WalletOrderRequest struct { + _tab flatbuffers.Table +} + +func GetRootAsWalletOrderRequest(buf []byte, offset flatbuffers.UOffsetT) *WalletOrderRequest { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &WalletOrderRequest{} + x.Init(buf, n+offset) + return x +} + +func FinishWalletOrderRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsWalletOrderRequest(buf []byte, offset flatbuffers.UOffsetT) *WalletOrderRequest { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &WalletOrderRequest{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedWalletOrderRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *WalletOrderRequest) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *WalletOrderRequest) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *WalletOrderRequest) ProductId() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func WalletOrderRequestStart(builder *flatbuffers.Builder) { + builder.StartObject(1) +} +func WalletOrderRequestAddProductId(builder *flatbuffers.Builder, productId flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(productId), 0) +} +func WalletOrderRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/fbs/scrabblefb/WalletOrderResponse.go b/pkg/fbs/scrabblefb/WalletOrderResponse.go new file mode 100644 index 0000000..b3aa961 --- /dev/null +++ b/pkg/fbs/scrabblefb/WalletOrderResponse.go @@ -0,0 +1,71 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package scrabblefb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type WalletOrderResponse struct { + _tab flatbuffers.Table +} + +func GetRootAsWalletOrderResponse(buf []byte, offset flatbuffers.UOffsetT) *WalletOrderResponse { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &WalletOrderResponse{} + x.Init(buf, n+offset) + return x +} + +func FinishWalletOrderResponseBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsWalletOrderResponse(buf []byte, offset flatbuffers.UOffsetT) *WalletOrderResponse { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &WalletOrderResponse{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedWalletOrderResponseBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *WalletOrderResponse) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *WalletOrderResponse) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *WalletOrderResponse) OrderId() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *WalletOrderResponse) RedirectUrl() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func WalletOrderResponseStart(builder *flatbuffers.Builder) { + builder.StartObject(2) +} +func WalletOrderResponseAddOrderId(builder *flatbuffers.Builder, orderId flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(orderId), 0) +} +func WalletOrderResponseAddRedirectUrl(builder *flatbuffers.Builder, redirectUrl flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(redirectUrl), 0) +} +func WalletOrderResponseEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/ui/src/gen/fbs/scrabblefb.ts b/ui/src/gen/fbs/scrabblefb.ts index df83fb6..c6dff38 100644 --- a/ui/src/gen/fbs/scrabblefb.ts +++ b/ui/src/gen/fbs/scrabblefb.ts @@ -86,6 +86,8 @@ export { UpdateProfileRequest } from './scrabblefb/update-profile-request.js'; export { VKLoginRequest } from './scrabblefb/vklogin-request.js'; export { Wallet } from './scrabblefb/wallet.js'; export { WalletBuyRequest } from './scrabblefb/wallet-buy-request.js'; +export { WalletOrderRequest } from './scrabblefb/wallet-order-request.js'; +export { WalletOrderResponse } from './scrabblefb/wallet-order-response.js'; export { WalletSegment } from './scrabblefb/wallet-segment.js'; export { WordCheckResult } from './scrabblefb/word-check-result.js'; export { YourTurnEvent } from './scrabblefb/your-turn-event.js'; diff --git a/ui/src/gen/fbs/scrabblefb/wallet-order-request.ts b/ui/src/gen/fbs/scrabblefb/wallet-order-request.ts new file mode 100644 index 0000000..f4e4d8c --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/wallet-order-request.ts @@ -0,0 +1,48 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +import * as flatbuffers from 'flatbuffers'; + +export class WalletOrderRequest { + bb: flatbuffers.ByteBuffer|null = null; + bb_pos = 0; + __init(i:number, bb:flatbuffers.ByteBuffer):WalletOrderRequest { + this.bb_pos = i; + this.bb = bb; + return this; +} + +static getRootAsWalletOrderRequest(bb:flatbuffers.ByteBuffer, obj?:WalletOrderRequest):WalletOrderRequest { + return (obj || new WalletOrderRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +static getSizePrefixedRootAsWalletOrderRequest(bb:flatbuffers.ByteBuffer, obj?:WalletOrderRequest):WalletOrderRequest { + bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH); + return (obj || new WalletOrderRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +productId():string|null +productId(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null +productId(optionalEncoding?:any):string|Uint8Array|null { + const offset = this.bb!.__offset(this.bb_pos, 4); + return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; +} + +static startWalletOrderRequest(builder:flatbuffers.Builder) { + builder.startObject(1); +} + +static addProductId(builder:flatbuffers.Builder, productIdOffset:flatbuffers.Offset) { + builder.addFieldOffset(0, productIdOffset, 0); +} + +static endWalletOrderRequest(builder:flatbuffers.Builder):flatbuffers.Offset { + const offset = builder.endObject(); + return offset; +} + +static createWalletOrderRequest(builder:flatbuffers.Builder, productIdOffset:flatbuffers.Offset):flatbuffers.Offset { + WalletOrderRequest.startWalletOrderRequest(builder); + WalletOrderRequest.addProductId(builder, productIdOffset); + return WalletOrderRequest.endWalletOrderRequest(builder); +} +} diff --git a/ui/src/gen/fbs/scrabblefb/wallet-order-response.ts b/ui/src/gen/fbs/scrabblefb/wallet-order-response.ts new file mode 100644 index 0000000..b7314e9 --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/wallet-order-response.ts @@ -0,0 +1,60 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +import * as flatbuffers from 'flatbuffers'; + +export class WalletOrderResponse { + bb: flatbuffers.ByteBuffer|null = null; + bb_pos = 0; + __init(i:number, bb:flatbuffers.ByteBuffer):WalletOrderResponse { + this.bb_pos = i; + this.bb = bb; + return this; +} + +static getRootAsWalletOrderResponse(bb:flatbuffers.ByteBuffer, obj?:WalletOrderResponse):WalletOrderResponse { + return (obj || new WalletOrderResponse()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +static getSizePrefixedRootAsWalletOrderResponse(bb:flatbuffers.ByteBuffer, obj?:WalletOrderResponse):WalletOrderResponse { + bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH); + return (obj || new WalletOrderResponse()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +orderId():string|null +orderId(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null +orderId(optionalEncoding?:any):string|Uint8Array|null { + const offset = this.bb!.__offset(this.bb_pos, 4); + return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; +} + +redirectUrl():string|null +redirectUrl(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null +redirectUrl(optionalEncoding?:any):string|Uint8Array|null { + const offset = this.bb!.__offset(this.bb_pos, 6); + return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; +} + +static startWalletOrderResponse(builder:flatbuffers.Builder) { + builder.startObject(2); +} + +static addOrderId(builder:flatbuffers.Builder, orderIdOffset:flatbuffers.Offset) { + builder.addFieldOffset(0, orderIdOffset, 0); +} + +static addRedirectUrl(builder:flatbuffers.Builder, redirectUrlOffset:flatbuffers.Offset) { + builder.addFieldOffset(1, redirectUrlOffset, 0); +} + +static endWalletOrderResponse(builder:flatbuffers.Builder):flatbuffers.Offset { + const offset = builder.endObject(); + return offset; +} + +static createWalletOrderResponse(builder:flatbuffers.Builder, orderIdOffset:flatbuffers.Offset, redirectUrlOffset:flatbuffers.Offset):flatbuffers.Offset { + WalletOrderResponse.startWalletOrderResponse(builder); + WalletOrderResponse.addOrderId(builder, orderIdOffset); + WalletOrderResponse.addRedirectUrl(builder, redirectUrlOffset); + return WalletOrderResponse.endWalletOrderResponse(builder); +} +} From 4f6c22d66973be027840d6e69c8e0b5c5cedf7cc Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Thu, 9 Jul 2026 17:33:22 +0200 Subject: [PATCH 5/9] feat(gateway): the Robokassa /pay/ edge routes Add the public /pay/robokassa/result callback proxy (rate-limited; forwards the provider's form parameters to the backend intake, the single writer, and echoes its "OK" back to Robokassa) and the /pay/robokassa/{success,fail} browser-return redirects into the app. Route /pay/* to the gateway in the contour Caddyfile so the callback reaches the edge, not the landing catch-all. The backend intake now returns the echo body as JSON for the gateway to relay. --- backend/internal/server/handlers_intake.go | 3 +- deploy/caddy/Caddyfile | 2 +- gateway/internal/backendclient/api.go | 14 +++++++ gateway/internal/connectsrv/server.go | 49 ++++++++++++++++++++++ 4 files changed, 66 insertions(+), 2 deletions(-) diff --git a/backend/internal/server/handlers_intake.go b/backend/internal/server/handlers_intake.go index de19c44..6c3184f 100644 --- a/backend/internal/server/handlers_intake.go +++ b/backend/internal/server/handlers_intake.go @@ -121,5 +121,6 @@ func (s *Server) handleRobokassaResult(c *gin.Context) { s.log.Error("record payment event failed", zap.String("order", orderID.String()), zap.Error(err)) } } - c.String(http.StatusOK, "OK"+v.Get("InvId")) + // The gateway echoes response verbatim to Robokassa, which requires the body "OK". + c.JSON(http.StatusOK, gin.H{"response": "OK" + v.Get("InvId")}) } diff --git a/deploy/caddy/Caddyfile b/deploy/caddy/Caddyfile index 578bd81..1b19493 100644 --- a/deploy/caddy/Caddyfile +++ b/deploy/caddy/Caddyfile @@ -86,7 +86,7 @@ # The game SPA and the Connect edge are served by the gateway. Strip any # client-supplied X-Scrabble-Honeypot here so the gateway only ever honours the # tag the honeypot block sets below (a client cannot self-tag a real request). - @gateway path /app /app/* /telegram /telegram/* /vk /vk/* /dict/* /dl/* /metrics/* /telemetry/* /scrabble.edge.v1.Gateway/* + @gateway path /app /app/* /telegram /telegram/* /vk /vk/* /dict/* /dl/* /pay/* /metrics/* /telemetry/* /scrabble.edge.v1.Gateway/* handle @gateway { reverse_proxy gateway:8081 { header_up -X-Scrabble-Honeypot diff --git a/gateway/internal/backendclient/api.go b/gateway/internal/backendclient/api.go index 09f3352..8d033b4 100644 --- a/gateway/internal/backendclient/api.go +++ b/gateway/internal/backendclient/api.go @@ -414,6 +414,20 @@ func (c *Client) WalletOrder(ctx context.Context, userID, productID string) (Wal return out, err } +// robokassaResultResp is the backend intake's reply: the body to echo back to Robokassa. +type robokassaResultResp struct { + Response string `json:"response"` +} + +// RobokassaResult forwards a Robokassa Result callback's parameters to the backend intake (the +// single writer, which verifies the signature and credits) and returns the body to echo to +// Robokassa ("OK"). params carries the provider's raw form fields. +func (c *Client) RobokassaResult(ctx context.Context, params map[string]string) (string, error) { + var out robokassaResultResp + err := c.do(ctx, http.MethodPost, "/api/v1/internal/payments/robokassa/result", "", "", params, &out) + return out.Response, err +} + // CatalogAtomResp is one atom line of a storefront product: the value type it grants and quantity. type CatalogAtomResp struct { AtomType string `json:"atom_type"` diff --git a/gateway/internal/connectsrv/server.go b/gateway/internal/connectsrv/server.go index 704de92..2aaac25 100644 --- a/gateway/internal/connectsrv/server.go +++ b/gateway/internal/connectsrv/server.go @@ -196,6 +196,11 @@ func (s *Server) HTTPHandler() http.Handler { // through this session-gated route (not public); see dictBytesHandler. mux.Handle("/dict/", s.dictBytesHandler()) mux.Handle("/dl/", s.exportDownloadHandler()) + // Direct-rail (Robokassa) return + callback routes: the server Result callback (the single + // crediting signal, proxied to the backend intake) and the browser Success/Fail redirects. + mux.Handle("/pay/robokassa/result", s.robokassaResultHandler()) + mux.Handle("/pay/robokassa/success", s.robokassaRedirectHandler()) + mux.Handle("/pay/robokassa/fail", s.robokassaRedirectHandler()) // The client posts its local-move-preview adoption telemetry here (session-gated). mux.Handle("/metrics/local-eval", s.localEvalMetricsHandler()) // The index.html boot guard beacons here when it turns a client away on the unsupported-engine @@ -482,6 +487,50 @@ func (s *Server) exportDownloadHandler() http.Handler { }) } +// robokassaResultHandler proxies the Robokassa Result callback to the backend intake (the single +// writer). It rate-limits per IP, forwards the provider's form parameters, and echoes the backend's +// "OK" to Robokassa on success; any error tells Robokassa the notification was not accepted, +// so it retries. The gateway does not verify the signature — the backend holds the secret. +func (s *Server) robokassaResultHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if s.backend == nil { + http.NotFound(w, r) + return + } + ip := peerIP(r.RemoteAddr, r.Header) + if !s.limiter.Allow("public:"+ip, s.publicPolicy) { + s.noteRateLimited(r.Context(), classPublic, ip, "robokassa-result") + http.Error(w, "rate limited", http.StatusTooManyRequests) + return + } + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + params := make(map[string]string, len(r.Form)) + for k := range r.Form { + params[k] = r.Form.Get(k) + } + resp, err := s.backend.RobokassaResult(r.Context(), params) + if err != nil { + s.log.Warn("robokassa result proxy failed", zap.Error(err)) + http.Error(w, "not accepted", http.StatusBadGateway) + return + } + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + _, _ = w.Write([]byte(resp)) + }) +} + +// robokassaRedirectHandler redirects the customer's browser back into the app after Robokassa's +// Success/Fail return. The credit rides the server Result callback, never these redirects, so this +// only navigates home; the wallet reflects the credit once the callback lands. +func (s *Server) robokassaRedirectHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/app/", http.StatusSeeOther) + }) +} + func (s *Server) dictBytesHandler() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if s.backend == nil { From 936a70ab94e95aca74629c03cfa26db744a5a421 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Thu, 9 Jul 2026 17:43:02 +0200 Subject: [PATCH 6/9] feat(ui): wire the chip-pack purchase to Robokassa Replace the disabled "Soon" pack action with a real purchase: the Wallet opens a money order (wallet.order) and sends the player to the provider's hosted-payment page (window.open via openExternalUrl); the chips are credited later by the verified server callback. Add a public-offer link under the packs (paying accepts the offer). Codec order round-trip unit test + a mock-e2e purchase test; the Google Play stub and the chip-spend paths are unchanged. --- ui/e2e/wallet.spec.ts | 23 +++++++++++++++++++++-- ui/src/lib/client.ts | 4 ++++ ui/src/lib/codec.test.ts | 21 +++++++++++++++++++++ ui/src/lib/codec.ts | 16 ++++++++++++++++ ui/src/lib/i18n/en.ts | 2 +- ui/src/lib/i18n/ru.ts | 2 +- ui/src/lib/mock/client.ts | 9 +++++++++ ui/src/lib/model.ts | 8 ++++++++ ui/src/lib/transport.ts | 3 +++ ui/src/screens/Wallet.svelte | 32 +++++++++++++++++++++++++++++++- 10 files changed, 115 insertions(+), 5 deletions(-) diff --git a/ui/e2e/wallet.spec.ts b/ui/e2e/wallet.spec.ts index df85476..4298fd6 100644 --- a/ui/e2e/wallet.spec.ts +++ b/ui/e2e/wallet.spec.ts @@ -37,8 +37,27 @@ test('wallet: balances, benefits and the storefront render for a durable account await expect(page.getByTestId('product')).toHaveCount(3); const pack = page.locator('[data-kind="pack"]'); await expect(pack).toContainText('₽'); - // The pack purchase (money intake) is not wired yet, so its action is the disabled 'Soon'. - await expect(pack.getByRole('button', { name: 'Soon' })).toBeDisabled(); + // The pack purchase is wired to money intake: an enabled Buy action, with the public-offer link. + await expect(pack.getByTestId('buy-pack')).toBeEnabled(); + await expect(page.getByTestId('offer')).toContainText('Public offer'); +}); + +test('wallet: buying a chip pack opens the provider payment page', async ({ page }) => { + await loginLobby(page); + await openWallet(page); + + // The purchase opens the provider's hosted-payment page in a new tab (window.open); capture it. + await page.evaluate(() => { + (window as { __opened?: string }).__opened = ''; + window.open = ((u: string) => { + (window as { __opened?: string }).__opened = u; + return null; + }) as typeof window.open; + }); + await page.locator('[data-kind="pack"]').getByTestId('buy-pack').click(); + await expect + .poll(() => page.evaluate(() => (window as { __opened?: string }).__opened)) + .toContain('robokassa'); }); test('wallet: the tab sits between Friends and About', async ({ page }) => { diff --git a/ui/src/lib/client.ts b/ui/src/lib/client.ts index 738466c..009a994 100644 --- a/ui/src/lib/client.ts +++ b/ui/src/lib/client.ts @@ -35,6 +35,7 @@ import type { Variant, WordCheckResult, Wallet, + WalletOrder, Catalog, } from './model'; @@ -147,6 +148,9 @@ export interface GatewayClient { /** walletBuy spends chips on a chip-priced value and returns the updated wallet. Gate-checked * server-side: an untrusted/frozen context or an insufficient balance is refused. */ walletBuy(productId: string): Promise; + /** walletOrder opens a money order to fund a chip pack and returns the provider launch URL the + * client opens; chips are credited later, by the verified server callback. Direct rail only. */ + walletOrder(productId: string): Promise; // --- friends --- friendsList(): Promise; diff --git a/ui/src/lib/codec.test.ts b/ui/src/lib/codec.test.ts index 3ef1a78..127f8bf 100644 --- a/ui/src/lib/codec.test.ts +++ b/ui/src/lib/codec.test.ts @@ -17,6 +17,8 @@ import { decodeWallet, decodeCatalog, encodeWalletBuy, + encodeWalletOrder, + decodeWalletOrder, decodeSession, decodeFeedbackState, decodeFeedbackUnread, @@ -73,6 +75,25 @@ describe('codec', () => { expect(w.hints).toBe(5); }); + it('round-trips the wallet order request and response', () => { + // order request: pack id survives the wire + const req = fb.WalletOrderRequest.getRootAsWalletOrderRequest(new ByteBuffer(encodeWalletOrder('pack-7'))); + expect(req.productId()).toBe('pack-7'); + + // order response: the created id and the provider launch URL decode back + const b = new Builder(64); + const oid = b.createString('order-123'); + const url = b.createString('https://pay.example/abc'); + fb.WalletOrderResponse.startWalletOrderResponse(b); + fb.WalletOrderResponse.addOrderId(b, oid); + fb.WalletOrderResponse.addRedirectUrl(b, url); + b.finish(fb.WalletOrderResponse.endWalletOrderResponse(b)); + + const order = decodeWalletOrder(b.asUint8Array()); + expect(order.orderId).toBe('order-123'); + expect(order.redirectUrl).toBe('https://pay.example/abc'); + }); + it('round-trips the catalog view (value + pack)', () => { const b = new Builder(256); diff --git a/ui/src/lib/codec.ts b/ui/src/lib/codec.ts index 38a03c0..709ec03 100644 --- a/ui/src/lib/codec.ts +++ b/ui/src/lib/codec.ts @@ -39,6 +39,7 @@ import type { MoveResult, Profile, Wallet, + WalletOrder, WalletSegment, Catalog, CatalogProduct, @@ -373,6 +374,15 @@ export function encodeWalletBuy(productId: string): Uint8Array { return finish(b, fb.WalletBuyRequest.endWalletBuyRequest(b)); } +// encodeWalletOrder wraps the pack id for a money order (POST /user/wallet/order). +export function encodeWalletOrder(productId: string): Uint8Array { + const b = new Builder(64); + const pid = b.createString(productId); + fb.WalletOrderRequest.startWalletOrderRequest(b); + fb.WalletOrderRequest.addProductId(b, pid); + return finish(b, fb.WalletOrderRequest.endWalletOrderRequest(b)); +} + // decodeWallet reads the wallet payload: the context-visible chip segments and the // context-applicable benefits. export function decodeWallet(buf: Uint8Array): Wallet { @@ -391,6 +401,12 @@ export function decodeWallet(buf: Uint8Array): Wallet { }; } +// decodeWalletOrder reads the created order: its id and the provider launch URL the client opens. +export function decodeWalletOrder(buf: Uint8Array): WalletOrder { + const o = fb.WalletOrderResponse.getRootAsWalletOrderResponse(new ByteBuffer(buf)); + return { orderId: s(o.orderId()), redirectUrl: s(o.redirectUrl()) }; +} + // decodeCatalog reads the storefront payload: the context-visible products, each a chip-priced // value or a chip pack priced in the context's payment method, with its atom composition. export function decodeCatalog(buf: Uint8Array): Catalog { diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index 3924ed3..373fafe 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -242,7 +242,7 @@ export const en = { 'wallet.store': 'Store', 'wallet.empty': 'The store is empty for now', 'wallet.buy': 'Buy', - 'wallet.soon': 'Soon', + 'wallet.offer': 'Public offer', 'wallet.gpStub': 'To buy chips, install the RuStore build.', 'wallet.cur.RUB': '₽', 'wallet.cur.VOTE': 'votes', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index 4c58d99..28055c1 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -242,7 +242,7 @@ export const ru: Record = { 'wallet.store': 'Магазин', 'wallet.empty': 'Магазин пока пуст', 'wallet.buy': 'Купить', - 'wallet.soon': 'Скоро', + 'wallet.offer': 'Публичная оферта', 'wallet.gpStub': 'Чтобы покупать фишки, установите версию из RuStore.', 'wallet.cur.RUB': '₽', 'wallet.cur.VOTE': 'голосов', diff --git a/ui/src/lib/mock/client.ts b/ui/src/lib/mock/client.ts index 84b5764..2314208 100644 --- a/ui/src/lib/mock/client.ts +++ b/ui/src/lib/mock/client.ts @@ -41,6 +41,7 @@ import type { Variant, WordCheckResult, Wallet, + WalletOrder, Catalog, } from '../model'; import { valueForLetter } from '../alphabet'; @@ -225,6 +226,14 @@ export class MockGateway implements GatewayClient { } return this.cloneWallet(); } + + async walletOrder(productId: string): Promise { + const p = this.mockCatalog.products.find((x) => x.productId === productId); + if (!p || p.kind !== 'pack') throw new GatewayError('not_a_pack'); + // No real provider in mock: return a stub launch URL so the e2e can assert the purchase reaches + // the provider hand-off without leaving the app. + return { orderId: 'mock-order-' + productId, redirectUrl: 'https://mock.local/robokassa?order=' + productId }; + } private cloneWallet(): Wallet { return { segments: this.mockWallet.segments.map((s) => ({ ...s })), diff --git a/ui/src/lib/model.ts b/ui/src/lib/model.ts index e376f58..d71a9d7 100644 --- a/ui/src/lib/model.ts +++ b/ui/src/lib/model.ts @@ -172,6 +172,14 @@ export interface Wallet { /** One atom line of a storefront product: the base value type it grants ("chips"/"hints"/ * "noads_days"/"tournament") and how many of it the product carries. */ +// WalletOrder is a created money order to fund a chip pack: its id and the provider launch URL the +// client opens (the Robokassa hosted-payment page). Chips arrive later, by the verified server +// callback. +export interface WalletOrder { + orderId: string; + redirectUrl: string; +} + export interface CatalogAtom { atomType: string; quantity: number; diff --git a/ui/src/lib/transport.ts b/ui/src/lib/transport.ts index 8794deb..7a7e5d2 100644 --- a/ui/src/lib/transport.ts +++ b/ui/src/lib/transport.ts @@ -237,6 +237,9 @@ export function createTransport(baseUrl: string): GatewayClient { async walletBuy(productId: string) { return codec.decodeWallet(await exec('wallet.buy', codec.encodeWalletBuy(productId))); }, + async walletOrder(productId: string) { + return codec.decodeWalletOrder(await exec('wallet.order', codec.encodeWalletOrder(productId))); + }, async friendsList() { return codec.decodeFriendList(await exec('friends.list', codec.empty())); diff --git a/ui/src/screens/Wallet.svelte b/ui/src/screens/Wallet.svelte index 7ade3db..23c5a29 100644 --- a/ui/src/screens/Wallet.svelte +++ b/ui/src/screens/Wallet.svelte @@ -6,6 +6,7 @@ import { t, i18n, type MessageKey } from '../lib/i18n/index.svelte'; import { executionContext, formatAmount, needsWebSpendWarning, type SpendContext } from '../lib/wallet'; import { isGooglePlayBuild } from '../lib/distribution'; + import { openExternalUrl } from '../lib/links'; import type { Wallet, Catalog, CatalogProduct } from '../lib/model'; // The Wallet section: the context-visible chip balances, the active benefits, and the storefront. @@ -78,6 +79,22 @@ busy = false; } } + + // onOrder funds a chip pack with money: it opens a pending order and sends the player to the + // provider's hosted-payment page. The chips are credited later, by the verified server callback; + // paying accepts the public offer (linked below the packs). + async function onOrder(p: CatalogProduct) { + if (busy) return; + busy = true; + try { + const order = await gateway.walletOrder(p.productId); + openExternalUrl(order.redirectUrl); + } catch (e) { + handleError(e); + } finally { + busy = false; + } + }
@@ -120,9 +137,14 @@
{p.title} {formatAmount(p.moneyAmount, p.moneyCurrency)} {currencyLabel(p.moneyCurrency)} - +
{/each} + {#if packs.length > 0} +

+ {t('wallet.offer')} +

+ {/if} {/if} {#if !loading && values.length === 0 && (gpBuild || packs.length === 0)} @@ -207,6 +229,14 @@ border: 1px dashed var(--border); border-radius: var(--radius-sm); } + .offer { + margin: 2px 0 0; + text-align: center; + font-size: 0.85rem; + } + .offer a { + color: var(--text-muted); + } .warn-body { margin: 0 0 14px; } From be0e4995f36a833f736eaeafb1f23634fcd3a984 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Thu, 9 Jul 2026 17:45:54 +0200 Subject: [PATCH 7/9] feat(deploy): wire the Robokassa direct rail into the contour Map the Robokassa merchant login + Password1/Password2 into the backend container env from the deploy secrets (TEST_BACKEND_ROBOKASSA_*), and force IsTest on the test contour so it can never take real money. An empty login leaves the direct order + callback endpoints unregistered. --- .gitea/workflows/ci.yaml | 7 +++++++ deploy/docker-compose.yml | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 811bcfa..9a215d9 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -366,6 +366,13 @@ jobs: SMTP_RELAY_HOST: ${{ vars.SMTP_RELAY_HOST }} SMTP_RELAY_PORT: ${{ vars.SMTP_RELAY_PORT }} SMTP_RELAY_TLS: ${{ vars.SMTP_RELAY_TLS }} + # Direct-rail (Robokassa) sandbox intake on the contour: the test shop's merchant login + + # Password1/Password2. IsTest is forced to 1 below so the contour can never take real money + # (independent of the shop's own mode). Empty login leaves the direct rail off. + ROBOKASSA_MERCHANT_LOGIN: ${{ secrets.TEST_BACKEND_ROBOKASSA_MERCHANT_LOGIN }} + ROBOKASSA_PASSWORD1: ${{ secrets.TEST_BACKEND_ROBOKASSA_PASSWORD1 }} + ROBOKASSA_PASSWORD2: ${{ secrets.TEST_BACKEND_ROBOKASSA_PASSWORD2 }} + ROBOKASSA_TEST: "1" SMTP_RELAY_FROM: ${{ vars.TEST_SMTP_RELAY_FROM }} # Operator alerts: backend admin emails (new feedback / complaints) + Grafana # infra alerts. Distinct senders + recipients; Grafana uses the relay's STARTTLS diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 919fee2..024d719 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -161,6 +161,13 @@ services: # recipient(s) (comma-separated allowed). Both empty disables the alert worker. BACKEND_SMTP_ADMIN_FROM: ${SMTP_RELAY_ADMIN_FROM:-} BACKEND_ADMIN_EMAIL: ${ADMIN_EMAIL:-} + # Direct-rail (Robokassa) payment intake: the merchant login + Password1/Password2 and the + # test-mode flag (deploy env: TEST_/PROD_BACKEND_ROBOKASSA_*). An empty login leaves the + # direct order and Result-callback endpoints unregistered (the rail is off). + BACKEND_ROBOKASSA_MERCHANT_LOGIN: ${ROBOKASSA_MERCHANT_LOGIN:-} + BACKEND_ROBOKASSA_PASSWORD1: ${ROBOKASSA_PASSWORD1:-} + BACKEND_ROBOKASSA_PASSWORD2: ${ROBOKASSA_PASSWORD2:-} + BACKEND_ROBOKASSA_TEST: ${ROBOKASSA_TEST:-} # The dictionary lives on a named volume seeded from the image on first boot # (the image's /opt/dawg is owned by the nonroot UID, which the fresh volume # inherits). The admin console writes new version subdirectories here, and the From 04435a3283c7365ddb9aeb47aa2fbb8ee6f814b0 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Thu, 9 Jul 2026 17:48:29 +0200 Subject: [PATCH 8/9] docs(payments): bake the E5 direct-rail decisions + a /pay/ CI probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Record the E5 delivery and resolved decisions in PLAN.md (Shp_order matching, order-id idempotency, cabinet-side receipt, never-negative → schema-free) and mark the stage WIP. Add a CI probe asserting /pay/robokassa/result reaches the gateway rather than the landing catch-all. --- .gitea/workflows/ci.yaml | 16 ++++++++++++++++ PLAN.md | 16 ++++++++++++++-- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 9a215d9..3b97e91 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -520,6 +520,22 @@ jobs: exit 1 fi + - name: Probe the /pay/ callback route reaches the gateway + run: | + set -u + # /pay/robokassa/result must reach the gateway, not fall to the landing catch-all. An + # unsigned probe is rejected downstream, so the gateway answers a 4xx/5xx (never a 200 or + # a 404 landing.html), which proves the edge route is wired. + out="$(docker run --rm --network edge alpine:3.20 wget -S -q -O /dev/null http://scrabble/pay/robokassa/result 2>&1 || true)" + echo "$out" | grep -E "HTTP/" || true + if echo "$out" | grep -qE "HTTP/1\.1 (4|5)[0-9][0-9]"; then + echo "ok: /pay/ reaches the gateway (non-landing response)" + else + echo "FAIL: /pay/robokassa/result did not reach the gateway (landing catch-all?)" + docker logs --tail 50 scrabble-gateway || true + exit 1 + fi + - name: Probe the /dict edge route reaches the gateway run: | set -u diff --git a/PLAN.md b/PLAN.md index f182eb9..3817dc0 100644 --- a/PLAN.md +++ b/PLAN.md @@ -34,7 +34,7 @@ status — without re-deriving decisions. | E2 | Currency + benefit core | 1 | DONE | | E3 | Wallet UI | 1 | DONE | | E4 | Durability (PITR) | 2 | DONE | -| E5 | Payment intake | 2 | TODO | +| E5 | Payment intake | 2 | WIP | | E6 | Ads | 2 | TODO | | E7 | Admin & reports | 2 | TODO | | E8 | Guest limits | — | TODO | @@ -504,7 +504,19 @@ maintenance window). Migrations stay expand-contract so image rollback remains D ## E5 — Payment intake -**Status:** TODO · **Release 2** · depends on: E0, E1, E2, E4 · mechanics: PAYMENTS §9, §12. +**Status:** WIP · **Release 2** · depends on: E0, E1, E2, E4 · mechanics: PAYMENTS §9, §12. + +**Delivery & baked decisions.** Shipped as a linear PR stack (owner's choice), Robokassa first. +Resolved: match the order by a Robokassa **`Shp_order`** custom parameter, not the numeric `InvId` +(an order id is a uuid); idempotency key = the order id (`provider_payment_id = order_id`); the НПД +receipt is formed **shop-side in the Robokassa cabinet**, so no `Receipt` parameter is sent; a +chargeback **never drives the balance negative** (D27 stands, `balances_chips_chk` kept), so E5 is +**schema-free** (no migration, no contour wipe). Delivered on `feature/payment-intake-robokassa`: +the offer page (`/offer/`), the order/`fund` engine (idempotent, honours an expired order), the +`internal/robokassa` adapter, the `POST /wallet/order` + internal Result-callback handlers (a D36 +confirmed-email gate on `direct`), the pending reaper, the `wallet.order` edge wire + the public +`/pay/*` routes, the Wallet purchase CTA, and the contour deploy env (IsTest forced). Remaining: +the `payment_events` dispatcher (delivery), then the VK and TG-Stars rails and refunds. **Goal.** Accept real money on all three rails into the payments domain: order-flow, verified provider callbacks, idempotency, the TG bot SQLite outbox, the event dispatcher, From 3367cc2bf17f7ab387752827c89d38ca64221885 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Thu, 9 Jul 2026 18:26:06 +0200 Subject: [PATCH 9/9] feat(payments): payment-event dispatcher + the provider-return UX Deliver payment_events to connected clients as an in-app wallet-refresh push: a background dispatcher drains undispatched events and publishes a KindNotification "payment" signal, marking each delivered; the client bumps a wallet-refresh counter the open Wallet screen watches, re-fetching in place. A return-focus refetch is the fallback. The Robokassa Success/Fail return now serves a self-closing page (the payment opens in a separate window) so the customer drops back into the live app instead of a cold start. Integration test for the event drain/mark queue. --- PLAN.md | 7 +++- backend/cmd/backend/main.go | 29 ++++++++++++++ .../internal/inttest/payments_intake_test.go | 39 +++++++++++++++++++ backend/internal/notify/notify.go | 4 ++ backend/internal/payments/service_intake.go | 11 ++++++ backend/internal/payments/store_intake.go | 36 +++++++++++++++++ gateway/internal/connectsrv/server.go | 26 +++++++++---- ui/src/lib/app.svelte.ts | 10 +++++ ui/src/screens/Wallet.svelte | 23 ++++++++++- 9 files changed, 174 insertions(+), 11 deletions(-) diff --git a/PLAN.md b/PLAN.md index 3817dc0..854a31d 100644 --- a/PLAN.md +++ b/PLAN.md @@ -515,8 +515,11 @@ chargeback **never drives the balance negative** (D27 stands, `balances_chips_ch the offer page (`/offer/`), the order/`fund` engine (idempotent, honours an expired order), the `internal/robokassa` adapter, the `POST /wallet/order` + internal Result-callback handlers (a D36 confirmed-email gate on `direct`), the pending reaper, the `wallet.order` edge wire + the public -`/pay/*` routes, the Wallet purchase CTA, and the contour deploy env (IsTest forced). Remaining: -the `payment_events` dispatcher (delivery), then the VK and TG-Stars rails and refunds. +`/pay/*` routes, the Wallet purchase CTA, the contour deploy env (IsTest forced), and the +`payment_events` dispatcher — an in-app wallet-refresh push (KindNotification `"payment"`) with a +self-closing provider-return page (the payment opens in a separate window) and a return-focus +refetch fallback. Remaining: the VK and TG-Stars rails and refunds; and hiding the ad banner on a +no-ads purchase (a spend-path `NotifyBanner`, deferred with the owner's agreement). **Goal.** Accept real money on all three rails into the payments domain: order-flow, verified provider callbacks, idempotency, the TG bot SQLite outbox, the event dispatcher, diff --git a/backend/cmd/backend/main.go b/backend/cmd/backend/main.go index 6c3fc64..cbdbc88 100644 --- a/backend/cmd/backend/main.go +++ b/backend/cmd/backend/main.go @@ -320,6 +320,9 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error { // 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) + // Deliver pending payment_events to connected clients as an in-app wallet-refresh push (the + // credit already landed in the ledger; a return-focus poll is the client-side fallback). + go runPaymentDispatcher(ctx, paymentsSvc, hub, logger) errc := make(chan error, 2) go func() { errc <- pushSrv.Run(ctx) }() @@ -350,6 +353,32 @@ func runOrderReaper(ctx context.Context, p *payments.Service, log *zap.Logger) { } } +// runPaymentDispatcher delivers pending payment_events to connected clients as a wallet-refresh +// signal (KindNotification / "payment"), marking each delivered, until ctx is cancelled. The credit +// already committed to the ledger; this is only the in-app push so an open wallet updates in place. +func runPaymentDispatcher(ctx context.Context, p *payments.Service, pub notify.Publisher, log *zap.Logger) { + t := time.NewTicker(3 * time.Second) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + evs, err := p.UndispatchedEvents(ctx, 50) + if err != nil { + log.Warn("payment dispatcher: read failed", zap.Error(err)) + continue + } + for _, e := range evs { + pub.Publish(notify.Notification(e.AccountID, notify.NotifyPayment)) + if err := p.MarkEventDispatched(ctx, e.EventID); err != nil { + log.Warn("payment dispatcher: mark failed", zap.String("event", e.EventID.String()), zap.Error(err)) + } + } + } + } +} + // 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/inttest/payments_intake_test.go b/backend/internal/inttest/payments_intake_test.go index 756d73c..17b4f2b 100644 --- a/backend/internal/inttest/payments_intake_test.go +++ b/backend/internal/inttest/payments_intake_test.go @@ -101,6 +101,45 @@ func TestPaymentsFundAmountMismatch(t *testing.T) { } } +// TestPaymentsEventDispatchDrain verifies the payment_events dispatcher queue: a recorded event is +// returned as undispatched until marked, then drops out (so the dispatcher delivers it once). +func TestPaymentsEventDispatchDrain(t *testing.T) { + ctx := context.Background() + svc := newPaymentsService() + acc := uuid.New() + if err := svc.RecordPaymentEvent(ctx, acc, nil, "succeeded", []byte(`{"chips":10,"source":"direct"}`)); err != nil { + t.Fatalf("record event: %v", err) + } + + // testDB is shared, so filter the queue to our account. + find := func() *payments.PaymentEvent { + evs, err := svc.UndispatchedEvents(ctx, 100) + if err != nil { + t.Fatalf("undispatched: %v", err) + } + for i := range evs { + if evs[i].AccountID == acc { + return &evs[i] + } + } + return nil + } + + mine := find() + if mine == nil { + t.Fatal("recorded event not in the undispatched queue") + } + if mine.Type != "succeeded" { + t.Errorf("event type = %s, want succeeded", mine.Type) + } + if err := svc.MarkEventDispatched(ctx, mine.EventID); err != nil { + t.Fatalf("mark dispatched: %v", err) + } + if find() != nil { + t.Error("event still undispatched after MarkEventDispatched") + } +} + // TestPaymentsExpiredOrderStillCredits verifies an expired pending order is still honoured by a // later valid callback (§9/D23: expiry is cosmetic, the money is real). func TestPaymentsExpiredOrderStillCredits(t *testing.T) { diff --git a/backend/internal/notify/notify.go b/backend/internal/notify/notify.go index e95f0f2..8cfe7c8 100644 --- a/backend/internal/notify/notify.go +++ b/backend/internal/notify/notify.go @@ -73,6 +73,10 @@ const ( // (e.g. an email was confirmed via the one-tap deeplink opened in another browser), // so it re-fetches profile.get. It carries no payload. In-app only. NotifyProfile = "profile" + // NotifyPayment tells the client that the viewer's wallet changed from a payment-intake event + // (a credit or a refund), so it re-fetches the wallet in place. It carries no payload. In-app + // only; the payment_events dispatcher emits it after the crediting transaction commits. + NotifyPayment = "payment" // NotifyUserBlocked confirms to the blocker that a per-user block took effect, // carrying the blocked account, so every one of the blocker's sessions updates the // in-game block/add-friend controls and the struck name in place. It is delivered diff --git a/backend/internal/payments/service_intake.go b/backend/internal/payments/service_intake.go index e57aac9..ccf0ffe 100644 --- a/backend/internal/payments/service_intake.go +++ b/backend/internal/payments/service_intake.go @@ -77,3 +77,14 @@ func (s *Service) ExpireOrders(ctx context.Context) (int, error) { 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()) } + +// UndispatchedEvents returns up to limit payment events awaiting delivery. The dispatcher drains +// them and marks each delivered via MarkEventDispatched. +func (s *Service) UndispatchedEvents(ctx context.Context, limit int) ([]PaymentEvent, error) { + return s.store.undispatchedEvents(ctx, limit) +} + +// MarkEventDispatched stamps a payment event as delivered so it is not re-sent. +func (s *Service) MarkEventDispatched(ctx context.Context, eventID uuid.UUID) error { + return s.store.markEventDispatched(ctx, eventID, s.clock()) +} diff --git a/backend/internal/payments/store_intake.go b/backend/internal/payments/store_intake.go index e9b49ae..605a6d2 100644 --- a/backend/internal/payments/store_intake.go +++ b/backend/internal/payments/store_intake.go @@ -292,6 +292,42 @@ func (s *Store) insertPaymentEvent(ctx context.Context, accountID uuid.UUID, ord return nil } +// PaymentEvent is an undispatched lifecycle event the dispatcher delivers to an account. +type PaymentEvent struct { + EventID uuid.UUID + AccountID uuid.UUID + Type string +} + +// undispatchedEvents reads up to limit payment events not yet delivered, oldest first. +func (s *Store) undispatchedEvents(ctx context.Context, limit int) ([]PaymentEvent, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT event_id, account_id, type FROM payments.payment_events + WHERE dispatched_at IS NULL ORDER BY created_at LIMIT $1`, limit) + if err != nil { + return nil, fmt.Errorf("payments: read undispatched events: %w", err) + } + defer rows.Close() + var out []PaymentEvent + for rows.Next() { + var e PaymentEvent + if err := rows.Scan(&e.EventID, &e.AccountID, &e.Type); err != nil { + return nil, fmt.Errorf("payments: scan event: %w", err) + } + out = append(out, e) + } + return out, rows.Err() +} + +// markEventDispatched stamps an event as delivered so it is not re-sent. +func (s *Store) markEventDispatched(ctx context.Context, eventID uuid.UUID, now time.Time) error { + if _, err := s.db.ExecContext(ctx, + `UPDATE payments.payment_events SET dispatched_at = $2 WHERE event_id = $1`, eventID, now); err != nil { + return fmt.Errorf("payments: mark event dispatched: %w", 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/gateway/internal/connectsrv/server.go b/gateway/internal/connectsrv/server.go index 2aaac25..9549732 100644 --- a/gateway/internal/connectsrv/server.go +++ b/gateway/internal/connectsrv/server.go @@ -199,8 +199,8 @@ func (s *Server) HTTPHandler() http.Handler { // Direct-rail (Robokassa) return + callback routes: the server Result callback (the single // crediting signal, proxied to the backend intake) and the browser Success/Fail redirects. mux.Handle("/pay/robokassa/result", s.robokassaResultHandler()) - mux.Handle("/pay/robokassa/success", s.robokassaRedirectHandler()) - mux.Handle("/pay/robokassa/fail", s.robokassaRedirectHandler()) + mux.Handle("/pay/robokassa/success", s.robokassaReturnHandler("Оплата принята.")) + mux.Handle("/pay/robokassa/fail", s.robokassaReturnHandler("Оплата не завершена.")) // The client posts its local-move-preview adoption telemetry here (session-gated). mux.Handle("/metrics/local-eval", s.localEvalMetricsHandler()) // The index.html boot guard beacons here when it turns a client away on the unsupported-engine @@ -522,12 +522,24 @@ func (s *Server) robokassaResultHandler() http.Handler { }) } -// robokassaRedirectHandler redirects the customer's browser back into the app after Robokassa's -// Success/Fail return. The credit rides the server Result callback, never these redirects, so this -// only navigates home; the wallet reflects the credit once the callback lands. -func (s *Server) robokassaRedirectHandler() http.Handler { +// robokassaReturnHandler serves a minimal self-closing page for Robokassa's Success/Fail browser +// return. The payment opens in a separate window, so on return this closes that window and drops the +// customer back into the live app (never a cold app start); a link is the fallback if the window +// cannot self-close. The credit rides the server Result callback, never this redirect — the wallet +// updates in place from the payment push, with a return-focus refetch as the fallback. +func (s *Server) robokassaReturnHandler(message string) http.Handler { + page := []byte(` +Оплата + +

` + message + `

+

Можно закрыть это окно и вернуться в приложение.

+

Открыть приложение

+ +`) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - http.Redirect(w, r, "/app/", http.StatusSeeOther) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + _, _ = w.Write(page) }) } diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index 173f59d..8c4882b 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -137,6 +137,9 @@ export const app = $state<{ /** Whether an operator feedback reply awaits the player, for the lobby ⚙️ badge (combined * with friend requests) and the Settings → Info badge. */ feedbackReplyUnread: boolean; + /** A monotone counter bumped when a payment-intake push signals the wallet changed; an open + * Wallet screen watches it and re-fetches in place. */ + walletRefresh: number; /** Whether to show the "outdated invite link" notice: set when a Telegram deep-link friend * code is already used/expired, so the visitor lands in the lobby with a gentle pointer to * the bot instead of a scary error on the Friends screen. */ @@ -175,6 +178,7 @@ export const app = $state<{ chatUnread: {}, messageUnread: {}, feedbackReplyUnread: false, + walletRefresh: 0, staleInvite: false, welcomeRedeem: false, resync: 0, @@ -439,6 +443,12 @@ function openStream(): void { if (e.sub === 'profile') { void refreshProfile(); } + // A payment-intake event credited (or refunded) the viewer's wallet: bump the signal an + // open Wallet screen watches, so it re-fetches in place (the return-focus poll is the + // fallback when the live stream is down). + if (e.sub === 'payment') { + app.walletRefresh++; + } void refreshNotifications(); } }, diff --git a/ui/src/screens/Wallet.svelte b/ui/src/screens/Wallet.svelte index 23c5a29..8377342 100644 --- a/ui/src/screens/Wallet.svelte +++ b/ui/src/screens/Wallet.svelte @@ -1,7 +1,7 @@