Merge pull request 'feat(payments): Robokassa direct-rail payment intake' (#223) from feature/payment-intake-robokassa into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 19s
CI / ui (push) Successful in 1m8s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m50s
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 19s
CI / ui (push) Successful in 1m8s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m50s
This commit was merged in pull request #223.
This commit is contained in:
@@ -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
|
||||
@@ -513,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
|
||||
|
||||
@@ -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,22 @@ 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, 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,
|
||||
|
||||
@@ -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,13 @@ 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)
|
||||
// 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) }()
|
||||
go func() { errc <- srv.Run(ctx) }()
|
||||
@@ -325,6 +333,52 @@ 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
//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")
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -147,12 +147,13 @@ func (m Money) Cmp(o Money) (int, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// String renders the amount as "<value> <currency>", 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 "<value> <currency>" (e.g. "149.50 RUB", "250 XTR").
|
||||
func (m Money) String() string {
|
||||
return m.Major() + " " + string(m.currency)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
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())
|
||||
}
|
||||
|
||||
// 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())
|
||||
}
|
||||
|
||||
// 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())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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
|
||||
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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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=<order id> (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[:])
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
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<InvId>". 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))
|
||||
}
|
||||
}
|
||||
// The gateway echoes response verbatim to Robokassa, which requires the body "OK<InvId>".
|
||||
c.JSON(http.StatusOK, gin.H{"response": "OK" + v.Get("InvId")})
|
||||
}
|
||||
@@ -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},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -396,6 +396,38 @@ 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
|
||||
}
|
||||
|
||||
// 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<InvId>"). 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"`
|
||||
|
||||
@@ -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.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
|
||||
@@ -482,6 +487,62 @@ 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<InvId>" 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))
|
||||
})
|
||||
}
|
||||
|
||||
// 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(`<!doctype html>
|
||||
<html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Оплата</title></head>
|
||||
<body style="font-family: system-ui, sans-serif; text-align: center; padding: 48px 20px; color: #1a1c20">
|
||||
<p style="font-size: 1.1rem">` + message + `</p>
|
||||
<p style="color: #5b6472">Можно закрыть это окно и вернуться в приложение.</p>
|
||||
<p><a href="/app/">Открыть приложение</a></p>
|
||||
<script>setTimeout(function () { try { window.close(); } catch (e) {} }, 1200);</script>
|
||||
</body></html>`)
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_, _ = w.Write(page)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) dictBytesHandler() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if s.backend == nil {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
+21
-2
@@ -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 }) => {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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<Wallet>;
|
||||
/** 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<WalletOrder>;
|
||||
|
||||
// --- friends ---
|
||||
friendsList(): Promise<AccountRef[]>;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -242,7 +242,7 @@ export const ru: Record<MessageKey, string> = {
|
||||
'wallet.store': 'Магазин',
|
||||
'wallet.empty': 'Магазин пока пуст',
|
||||
'wallet.buy': 'Купить',
|
||||
'wallet.soon': 'Скоро',
|
||||
'wallet.offer': 'Публичная оферта',
|
||||
'wallet.gpStub': 'Чтобы покупать фишки, установите версию из RuStore.',
|
||||
'wallet.cur.RUB': '₽',
|
||||
'wallet.cur.VOTE': 'голосов',
|
||||
|
||||
@@ -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<WalletOrder> {
|
||||
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 })),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import Modal from '../components/Modal.svelte';
|
||||
import { handleError } from '../lib/app.svelte';
|
||||
import { app, handleError } from '../lib/app.svelte';
|
||||
import { gateway } from '../lib/gateway';
|
||||
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.
|
||||
@@ -38,7 +39,26 @@
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
onMount(load);
|
||||
onMount(() => {
|
||||
void load();
|
||||
// Fallback to the payment push: re-fetch when the tab regains focus, i.e. the user returned
|
||||
// from the provider's payment window.
|
||||
const onVisible = () => {
|
||||
if (!document.hidden) void load();
|
||||
};
|
||||
document.addEventListener('visibilitychange', onVisible);
|
||||
return () => document.removeEventListener('visibilitychange', onVisible);
|
||||
});
|
||||
|
||||
// Main path: a payment-intake push bumps app.walletRefresh; re-fetch in place when it changes
|
||||
// (a credit landed while the screen is open).
|
||||
let seenRefresh = app.walletRefresh;
|
||||
$effect(() => {
|
||||
if (app.walletRefresh !== seenRefresh) {
|
||||
seenRefresh = app.walletRefresh;
|
||||
void load();
|
||||
}
|
||||
});
|
||||
|
||||
function sourceLabel(source: string): string {
|
||||
return t(`wallet.source.${source}` as MessageKey);
|
||||
@@ -78,6 +98,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;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="wallet" data-testid="wallet">
|
||||
@@ -120,9 +156,14 @@
|
||||
<div class="row product" data-testid="product" data-kind="pack" data-pid={p.productId}>
|
||||
<span class="name">{p.title}</span>
|
||||
<span class="price">{formatAmount(p.moneyAmount, p.moneyCurrency)} {currencyLabel(p.moneyCurrency)}</span>
|
||||
<button class="buy" disabled>{t('wallet.soon')}</button>
|
||||
<button class="buy" data-testid="buy-pack" disabled={busy} onclick={() => onOrder(p)}>{t('wallet.buy')}</button>
|
||||
</div>
|
||||
{/each}
|
||||
{#if packs.length > 0}
|
||||
<p class="offer" data-testid="offer">
|
||||
<a href="/offer/" target="_blank" rel="noopener noreferrer">{t('wallet.offer')}</a>
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if !loading && values.length === 0 && (gpBuild || packs.length === 0)}
|
||||
@@ -207,6 +248,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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user