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") + } +}