diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 2559c52..da090b2 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -242,11 +242,20 @@ The native Android app is a Capacitor 8 wrapper of the `ui` SPA, scaffolded unde - **Monetization** — «Фишка» currency, per-platform wallets, ads. Agreements in `docs/PAYMENTS_DECISIONS_ru.md`; a phased plan (E0–E9). go-jet money rule above applies. - **YooKassa (the direct rail since v1.x) does NOT sign its webhooks.** Authenticity is the - confirming `GET /v3/payments/{id}` — never the notification body — plus the published sender-IP - allowlist. Two extra guards ride on the confirmed object: its `metadata.order_id` must name the - order, and its `test` flag must match the shop's `IsTest` (a test-shop payment must never credit - real chips). Answer 200 for anything durably decided, 5xx only to be redelivered (YooKassa retries - 24h). + confirming `GET /v3/payments/{id}` — never the notification body. Two extra guards ride on the + confirmed object: its `metadata.order_id` must name the order, and its `test` flag must match the + shop's `IsTest` (a test-shop payment must never credit real chips). Answer 200 for anything + durably decided, 5xx only to be redelivered (YooKassa retries 24h). + - **Do NOT gate the notification on the sender IP** — tried, reverted. The contour sits behind a + tunnel and sees every caller as an internal address (`10.77.0.1`), so the allowlist rejected every + genuine notification and the credit fell back to the reconcile sweep. Same root cause as the + IP bans being prod-only. It bought nothing anyway: the order is resolved from the notification + metadata **before** any provider call, so a forged id costs one indexed read, and guessing a live + order id means guessing a uuid. + - **Never key a re-check interval off the order TTL.** The order lifetime answers "how long may a + customer take to pay"; the re-check answers "how soon do we notice a lost callback". Tying them + together made a failed notification cost the customer the full 30-minute TTL before the chips + landed. `payments.ReconcileAfter` is its own constant for that reason. - **Subscribe to `refund.succeeded`, not just the payment events.** The YooKassa cabinet lets an operator refund without touching our API, so without that event the money goes back while the chips stay credited. The reversal engine is **full-refund-only** (it rejects any amount other than diff --git a/PLAN.md b/PLAN.md index 66397df..5c39576 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1032,12 +1032,16 @@ when no YooKassa shop is configured, so reviving it is a credentials change (D47 It creates the payment (order id as both `Idempotence-Key` and `metadata.order_id`), records the provider payment id on the order (`AttachProviderPayment`; no migration — the column existed) and returns `confirmation_url`. D36's email anchor now also feeds the receipt (`ConfirmedEmail`). -- **Notification** — `/pay/yookassa/notify` (gateway: rate limit + raw-body proxy; backend: sender - allowlist, then the confirming `GetPayment`). Nothing in the body is acted on (D48). Guards: the - payment's metadata must name the order, and its `test` flag must match the shop's. `payment.canceled` - records a `failed` event. 200 = durably decided, 5xx = redeliver. -- **Reconcile** — `runOrderReaper` asks the provider about each pending order that reached its expiry - age carrying a payment id, and credits the ones really paid (D49). One request per order. +- **Notification** — `/pay/yookassa/notify` (gateway: rate limit + raw-body proxy; backend: the + confirming `GetPayment`). Nothing in the body is acted on (D48). Guards: the payment's metadata must + name the order, and its `test` flag must match the shop's. The sender address is deliberately not + checked (D48 rev) — it adds no security, and the order lookup that precedes any provider call is + the tighter guard against a forger driving traffic at the provider through us. + `payment.canceled` records a `failed` event. 200 = durably decided, 5xx = redeliver. +- **Reconcile** — `runOrderReaper` asks the provider about each pending order older than + `payments.ReconcileAfter` (a minute) carrying a payment id, and credits the ones really paid + (D49 rev — the threshold is deliberately not the order lifetime, which answers a different + question and made a failed notification cost the customer half an hour). - **Refund** — the `/_gm` button calls `POST /v3/refunds` first and records only on a **succeeded** refund, under the provider's own refund id (D50); a failure — or a still-`pending` refund — records nothing, and pressing again is safe. `refund.succeeded` is handled too (D52), because the merchant diff --git a/backend/internal/inttest/payments_yookassa_test.go b/backend/internal/inttest/payments_yookassa_test.go index a7ef926..9eae809 100644 --- a/backend/internal/inttest/payments_yookassa_test.go +++ b/backend/internal/inttest/payments_yookassa_test.go @@ -41,6 +41,9 @@ type fakeYooKassa struct { refundStatus string // refundCalls counts the refunds actually requested. refundCalls int + // paymentGets counts the confirming reads, so a test can assert a forged notification never + // reached the provider at all. + paymentGets int // refunds answers GET /refunds/{id}; a missing id is a 404. refunds map[string]yookassa.Refund // createdIdempotenceKey records the key sent on the last create-payment call. @@ -82,6 +85,7 @@ func (f *fakeYooKassa) serve(w http.ResponseWriter, r *http.Request) { f.payments[id] = p _ = json.NewEncoder(w).Encode(p) case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/payments/"): + f.paymentGets++ p, ok := f.payments[strings.TrimPrefix(r.URL.Path, "/payments/")] if !ok { w.WriteHeader(http.StatusNotFound) @@ -267,19 +271,37 @@ func TestYooKassaNotifyIsNotEvidence(t *testing.T) { } } -// TestYooKassaNotifyRejectsForeignSenders checks the sender allowlist: an address outside YooKassa's -// published ranges is refused before any provider call, so a forger cannot make us fan out requests. -func TestYooKassaNotifyRejectsForeignSenders(t *testing.T) { +// TestYooKassaNotifyForAnUnknownOrderCostsNoProviderCall pins what actually keeps a forger from +// using this endpoint to drive traffic at the provider on our behalf. The sender address is not +// checked — it cannot be, in a deployment that sees only its own tunnel — so the guard is that the +// order is resolved from the notification first: an id matching no order costs one indexed read and +// stops there. Guessing a live order id means guessing a uuid. +func TestYooKassaNotifyForAnUnknownOrderCostsNoProviderCall(t *testing.T) { + f, shop := newFakeYooKassa(t) + srv, _ := yookassaServer(t, shop) + + before := f.paymentGets + if code := postYooKassaNotify(t, srv, ykSenderIP, yookassa.EventPaymentSucceeded, "pay-forged", uuid.NewString()); code != http.StatusOK { + t.Fatalf("notify = %d, want 200", code) + } + if f.paymentGets != before { + t.Errorf("confirming reads = %d, want %d — a forged notification reached the provider", f.paymentGets, before) + } +} + +// TestYooKassaNotifyIsAcceptedFromAnyAddress: the contour (and any deployment behind a tunnel) sees +// only its own internal address as the sender, so a genuine notification must still be honoured. +func TestYooKassaNotifyIsAcceptedFromAnyAddress(t *testing.T) { f, shop := newFakeYooKassa(t) srv, pay := yookassaServer(t, shop) acc := provisionAccount(t) orderID, paymentID := seedYooKassaOrder(t, f, pay, acc) - if code := postYooKassaNotify(t, srv, "8.8.8.8", yookassa.EventPaymentSucceeded, paymentID, orderID.String()); code != http.StatusForbidden { - t.Fatalf("notify from a foreign address = %d, want 403", code) + if code := postYooKassaNotify(t, srv, "10.77.0.1", yookassa.EventPaymentSucceeded, paymentID, orderID.String()); code != http.StatusOK { + t.Fatalf("notify = %d, want 200", code) } - if got := readBalance(t, acc, "direct"); got != 0 { - t.Errorf("balance = %d, want 0 — a foreign sender credited chips", got) + if got := readBalance(t, acc, "direct"); got != 100 { + t.Errorf("balance = %d, want 100 — a genuine notification was refused on its source address", got) } } @@ -345,9 +367,10 @@ func TestYooKassaReconcileCreditsLostNotification(t *testing.T) { acc := provisionAccount(t) orderID, _ := seedYooKassaOrder(t, f, pay, acc) - // Age the order past its lifetime without any notification having been delivered. + // Age the order just past the re-check threshold — minutes, not the order's full lifetime — with + // no notification ever delivered. if _, err := testDB.ExecContext(context.Background(), - `UPDATE payments.orders SET created_at = now() - interval '2 days' WHERE order_id=$1`, orderID); err != nil { + `UPDATE payments.orders SET created_at = now() - interval '2 minutes' WHERE order_id=$1`, orderID); err != nil { t.Fatalf("age order: %v", err) } if n := srv.ReconcileYooKassaOrders(context.Background()); n != 1 { @@ -365,6 +388,27 @@ func TestYooKassaReconcileCreditsLostNotification(t *testing.T) { } } +// TestYooKassaReconcileSkipsFreshOrders keeps the sweep off an order the customer is still paying +// for: it re-checks only orders older than ReconcileAfter, so opening a payment page does not start +// polling the provider straight away. +func TestYooKassaReconcileSkipsFreshOrders(t *testing.T) { + f, shop := newFakeYooKassa(t) + srv, pay := yookassaServer(t, shop) + acc := provisionAccount(t) + seedYooKassaOrder(t, f, pay, acc) // just created + + before := f.paymentGets + if n := srv.ReconcileYooKassaOrders(context.Background()); n != 0 { + t.Errorf("reconciled %d orders, want 0 (the order is seconds old)", n) + } + if f.paymentGets != before { + t.Errorf("confirming reads = %d, want %d — a fresh order was polled", f.paymentGets, before) + } + if got := readBalance(t, acc, "direct"); got != 0 { + t.Errorf("balance = %d, want 0", got) + } +} + // TestYooKassaReconcileLeavesUnpaidOrders checks the sweep does not invent a credit for an order the // customer abandoned. func TestYooKassaReconcileLeavesUnpaidOrders(t *testing.T) { diff --git a/backend/internal/payments/service_intake.go b/backend/internal/payments/service_intake.go index d76e60d..06d44c8 100644 --- a/backend/internal/payments/service_intake.go +++ b/backend/internal/payments/service_intake.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "time" "github.com/google/uuid" ) @@ -127,16 +128,24 @@ func (s *Service) OrderByProviderPayment(ctx context.Context, provider, provider // run of provider calls. const reconcileBatch = 50 -// PendingForReconcile returns the pending orders that have reached their expiry age while carrying a -// provider payment id — the ones where the money may well have moved but no callback ever told us. -// The caller asks the provider for each one's real outcome before ExpireOrders writes them off. -// Orders that never reached a payment are not returned: there is nothing to ask about. +// ReconcileAfter is how old a pending order must be before the provider is asked what became of it. +// It is deliberately NOT the order lifetime: that governs when an unpaid order is written off, which +// answers "how long may a customer take to pay", a different question from "how soon should we +// notice a lost callback". Tying the two together would make a customer wait a full lifetime for +// chips whenever the notification path fails. A minute is long enough that an order the customer is +// still paying for is not polled, and short enough that a failure costs minutes, not half an hour. +const ReconcileAfter = time.Minute + +// PendingForReconcile returns the pending orders old enough to re-check that carry a provider payment +// id — the ones where the money may well have moved but no callback ever told us. The caller asks the +// provider for each one's real outcome. Orders that never reached a payment are not returned: there +// is nothing to ask about, and neither are orders younger than ReconcileAfter, so an order the +// customer is still paying for is left alone. +// +// The sweep repeats until the order settles or is written off, which bounds the provider calls one +// order can cause to its lifetime divided by the sweep interval — a handful, not an open-ended poll. func (s *Service) PendingForReconcile(ctx context.Context) ([]OrderRef, error) { - ttl, err := s.store.orderTTL(ctx) - if err != nil { - return nil, err - } - rows, err := s.store.pendingForReconcile(ctx, ttl, s.clock(), reconcileBatch) + rows, err := s.store.pendingForReconcile(ctx, int(ReconcileAfter.Seconds()), s.clock(), reconcileBatch) if err != nil { return nil, err } diff --git a/backend/internal/payments/store_intake.go b/backend/internal/payments/store_intake.go index f7f6e32..fe1f2c4 100644 --- a/backend/internal/payments/store_intake.go +++ b/backend/internal/payments/store_intake.go @@ -268,12 +268,12 @@ func (s *Store) attachProviderPayment(ctx context.Context, orderID uuid.UUID, pr return nil } -// pendingForReconcile reads the pending orders that have reached their expiry age and carry a -// provider payment id — the ones whose real outcome is still unknown to us because no callback ever -// arrived. The caller asks the provider what happened before the order is written off as expired. -// Orders with no provider payment id are skipped: the customer never got as far as a payment. -func (s *Store) pendingForReconcile(ctx context.Context, ttlSeconds int, now time.Time, limit int) ([]orderRow, error) { - cutoff := now.Add(-time.Duration(ttlSeconds) * time.Second) +// pendingForReconcile reads the pending orders older than ageSeconds that carry a provider payment +// id — the ones whose real outcome is still unknown to us because no callback ever arrived. The +// caller asks the provider what happened. Orders with no provider payment id are skipped: the +// customer never got as far as a payment. +func (s *Store) pendingForReconcile(ctx context.Context, ageSeconds int, now time.Time, limit int) ([]orderRow, error) { + cutoff := now.Add(-time.Duration(ageSeconds) * time.Second) var rows []model.Orders err := postgres.SELECT(table.Orders.AllColumns). FROM(table.Orders). diff --git a/backend/internal/server/handlers_yookassa.go b/backend/internal/server/handlers_yookassa.go index 7df35e9..07d27b4 100644 --- a/backend/internal/server/handlers_yookassa.go +++ b/backend/internal/server/handlers_yookassa.go @@ -95,21 +95,18 @@ func (s *Server) orderYooKassa(c *gin.Context, uid uuid.UUID, cxt payments.Conte // order exactly once (idempotent, and honoured even if the order expired); a canceled one records a // failed event so the customer is told the attempt did not go through. // +// The sender address is deliberately NOT checked. It would not add security — the confirming read is +// the whole boundary — and the one thing it did buy, keeping a forger from turning each fabricated +// notification into an outbound call of ours, is already bought earlier and far more tightly: the +// order is resolved from the notification's metadata first, and an id that matches no order costs a +// single indexed read and nothing else. Guessing a live order id means guessing a uuid. Meanwhile an +// address check is actively harmful in a deployment that cannot observe real client addresses (a +// contour behind a tunnel sees only its own), where it rejects genuine notifications. +// // The reply tells the provider whether to redeliver: 200 for anything durably decided — including a // duplicate and a permanent rejection, which no retry could fix — and 5xx only for a transient -// failure we want redelivered (YooKassa retries for 24 hours). An unrecognised sender is a 403, -// which is also redelivered: if YooKassa ever changed its published ranges, the retries plus the -// reconcile sweep would keep the money owed while the allowlist is corrected. +// failure we want redelivered (YooKassa retries for 24 hours). func (s *Server) handleYooKassaNotify(c *gin.Context) { - // The sender check runs before anything else. It is defence in depth — the confirming GetPayment - // is what actually establishes authenticity — but it is what stops a forger from turning each - // fabricated notification into an outbound API call of ours. The address is the true sender, - // forwarded by the gateway, which is the only hop that sees it. - if ip := clientIP(c); !yookassa.AllowedSender(ip) { - s.log.Warn("yookassa notify: sender is not a YooKassa address", zap.String("ip", ip)) - c.String(http.StatusForbidden, "forbidden") - return - } raw, err := io.ReadAll(io.LimitReader(c.Request.Body, maxNotifyBytes)) if err != nil { c.String(http.StatusInternalServerError, "error") diff --git a/backend/internal/yookassa/notify.go b/backend/internal/yookassa/notify.go index 1d174b3..aee343c 100644 --- a/backend/internal/yookassa/notify.go +++ b/backend/internal/yookassa/notify.go @@ -3,7 +3,6 @@ package yookassa import ( "encoding/json" "fmt" - "net/netip" ) // Notification events this integration subscribes to. An event name is ".": the @@ -71,42 +70,3 @@ func (n Notification) Refund() (Refund, error) { } return r, nil } - -// senderPrefixes are the address ranges YooKassa delivers notifications from -// (https://yookassa.ru/developers/using-api/webhooks). Single addresses are expressed as /32 and -// /128 prefixes. The list is defence in depth only — the confirming GetPayment is what actually -// establishes authenticity — so it is kept deliberately literal and easy to audit against the docs. -var senderPrefixes = []netip.Prefix{ - netip.MustParsePrefix("185.71.76.0/27"), - netip.MustParsePrefix("185.71.77.0/27"), - netip.MustParsePrefix("77.75.153.0/25"), - netip.MustParsePrefix("77.75.156.11/32"), - netip.MustParsePrefix("77.75.156.35/32"), - netip.MustParsePrefix("77.75.154.128/25"), - netip.MustParsePrefix("2a02:5180::/32"), -} - -// AllowedIP reports whether addr is one of YooKassa's notification senders. An IPv4-mapped IPv6 -// address is unmapped first, so a dual-stack listener's view of an IPv4 sender still matches. -func AllowedIP(addr netip.Addr) bool { - addr = addr.Unmap() - if !addr.IsValid() { - return false - } - for _, p := range senderPrefixes { - if p.Contains(addr) { - return true - } - } - return false -} - -// AllowedSender reports whether the textual address ip is one of YooKassa's notification senders. An -// unparseable address is not allowed. -func AllowedSender(ip string) bool { - addr, err := netip.ParseAddr(ip) - if err != nil { - return false - } - return AllowedIP(addr) -} diff --git a/backend/internal/yookassa/notify_test.go b/backend/internal/yookassa/notify_test.go index 69bca13..bbf0c9b 100644 --- a/backend/internal/yookassa/notify_test.go +++ b/backend/internal/yookassa/notify_test.go @@ -49,46 +49,6 @@ func TestNotificationPaymentNeedsAnID(t *testing.T) { } } -func TestAllowedSender(t *testing.T) { - allowed := []string{ - "185.71.76.0", "185.71.76.31", // /27 bounds - "185.71.77.15", - "77.75.153.0", "77.75.153.127", // /25 bounds - "77.75.156.11", "77.75.156.35", // the two single addresses - "77.75.154.128", "77.75.154.255", - "2a02:5180::1", "2a02:5180:ffff::abcd", - } - for _, ip := range allowed { - if !AllowedSender(ip) { - t.Errorf("%s rejected, want allowed", ip) - } - } - denied := []string{ - "185.71.76.32", "185.71.75.255", // just outside the /27 - "77.75.153.128", // just outside the /25 - "77.75.156.12", // adjacent to a single allowed address - "77.75.154.127", - "2a02:5181::1", - "8.8.8.8", "127.0.0.1", "", - "not an address", - } - for _, ip := range denied { - if AllowedSender(ip) { - t.Errorf("%s allowed, want rejected", ip) - } - } -} - -func TestAllowedSenderUnmapsIPv4(t *testing.T) { - // A dual-stack listener reports an IPv4 peer as ::ffff:a.b.c.d; it must still match the v4 range. - if !AllowedSender("::ffff:77.75.156.11") { - t.Error("an IPv4-mapped allowed sender was rejected") - } - if AllowedSender("::ffff:8.8.8.8") { - t.Error("an IPv4-mapped foreign sender was allowed") - } -} - func TestParseRefundNotification(t *testing.T) { n, err := ParseNotification([]byte(`{"type":"notification","event":"refund.succeeded", "object":{"id":"refund-1","status":"succeeded","payment_id":"pay-1", diff --git a/docs/PAYMENTS.md b/docs/PAYMENTS.md index c1d17c0..25e5067 100644 --- a/docs/PAYMENTS.md +++ b/docs/PAYMENTS.md @@ -261,16 +261,26 @@ which is what later lets the order be re-checked and refunded. The browser retur nothing in the body may be acted on. It only names a payment, which the server then re-reads with `GET /v3/payments/{id}`; only that answer is trusted. Two guards ride on it: the payment's metadata must name the order being credited, and its `test` flag must match the shop's, so a test-shop payment -can never credit real chips (nor a live payment be credited against test credentials). As defence in -depth — and to stop a forger turning each fabricated notification into an outbound call of ours — the -sender address is first checked against YooKassa's published ranges. The reply tells the provider -whether to redeliver: 200 for anything durably decided, including a duplicate and a permanent -rejection, and 5xx only for a transient failure (YooKassa redelivers for 24 hours). +can never credit real chips (nor a live payment be credited against test credentials). The reply +tells the provider whether to redeliver: 200 for anything durably decided, including a duplicate and +a permanent rejection, and 5xx only for a transient failure (YooKassa redelivers for 24 hours). -**Expiry-time reconciliation (D49).** No continuous polling. But a notification that is lost for good -would leave the money taken and the chips unowed, silently, so the existing pending-order reaper asks -the provider what became of each order that reached its expiry age carrying a payment id, and credits -the ones that were in fact paid. One request per order over its whole life. +The sender address is **not** checked (D48 rev). It would add no security — the confirming read is +the whole boundary — and the one thing it bought, keeping a forger from turning each fabricated +notification into an outbound call of ours, is already bought earlier and far more tightly: the order +is resolved from the notification's metadata **before** any provider call, so an id matching no order +costs one indexed read and stops there, and guessing a live order id means guessing a uuid. An +address check is also actively harmful wherever the deployment cannot observe real client addresses — +a contour behind a tunnel sees only its own — where it rejects genuine notifications. + +**Reconciliation on a short cadence (D49 rev).** No open-ended polling, but the check is **not** tied +to the order lifetime: that governs when an unpaid order is written off, which answers "how long may +a customer take to pay" — a different question from "how soon should we notice a lost callback". +Tying them together would make a customer wait a full lifetime for chips whenever the notification +path fails. The pending-order reaper instead asks the provider about every pending order older than a +minute that carries a payment id, and credits the ones that were in fact paid. That bounds the calls +one order can cause to its lifetime divided by the sweep interval — a handful — while a failure of +the primary path costs minutes rather than half an hour. **A declined payment is surfaced.** A YooKassa `payment.canceled` records a `failed` payment event, so the customer is told the attempt did not go through instead of watching a balance that never diff --git a/docs/PAYMENTS_DECISIONS_ru.md b/docs/PAYMENTS_DECISIONS_ru.md index 9b69b18..2671780 100644 --- a/docs/PAYMENTS_DECISIONS_ru.md +++ b/docs/PAYMENTS_DECISIONS_ru.md @@ -296,16 +296,27 @@ web+PWA / native Android+iOS через Capacitor). Владелец (самоз уведомления — только подсказка: оно называет платёж, который сервер перечитывает запросом `GET /v3/payments/{id}`, и действует **исключительно** по ответу API. Дополнительно: метаданные платежа должны называть тот самый заказ; флаг `test` платежа должен совпадать с флагом магазина - (платёж тестового магазина не начислит настоящие Фишки, и наоборот); адрес отправителя сверяется с - опубликованными диапазонами ЮKassa — эшелонированная защита, которая не даёт превратить каждое - фальшивое уведомление в наш исходящий запрос. Ответ провайдеру: 200 на всё окончательно решённое - (включая дубль и неустранимый отказ), 5xx только на временный сбой (ЮKassa повторяет 24 часа). -- **D49. Сверка — одна проверка на истечении заказа, без постоянного опроса.** «Или» в документации + (платёж тестового магазина не начислит настоящие Фишки, и наоборот). Ответ провайдеру: 200 на всё + окончательно решённое (включая дубль и неустранимый отказ), 5xx только на временный сбой (ЮKassa + повторяет 24 часа). + **Ревизия: адрес отправителя не проверяем.** Изначально сверяли с опубликованными диапазонами + ЮKassa как эшелонированную защиту. Оказалось лишним и вредным: единственное, что она давала — + не дать подделывателю превратить фальшивое уведомление в наш исходящий запрос — уже обеспечено + раньше и жёстче, потому что заказ ищется по метаданным ДО обращения к провайдеру (несуществующий + order_id стоит одного чтения по индексу; угадать живой — значит угадать uuid). А на тестовом + контуре, который за туннелем видит только свой внутренний адрес, проверка отбивала настоящие + уведомления — ровно как IP-баны, сделанные в репозитории prod-only по той же причине. +- **D49. Сверка — без постоянного опроса, но с коротким шагом (ревизия).** «Или» в документации ЮKassa адресовано тем, кто не хочет вебхуки; у нас вебхуки основные. Но безвозвратно потерянное уведомление оставило бы деньги списанными, а Фишки — не выданными, и молча. Поэтому существующий - жнец просроченных заказов перед списанием в `expired` спрашивает провайдера о судьбе каждого заказа, - дожившего до срока с идентификатором платежа, и начисляет реально оплаченные. Один запрос на заказ - за всю его жизнь; отдельный воркер не заводим. + жнец спрашивает провайдера о судьбе незакрытых заказов с идентификатором платежа и начисляет реально + оплаченные; отдельный воркер не заводим. + **Ревизия порога:** сперва проверка была привязана к возрасту истечения заказа (30 минут), и это + была ошибка — время жизни заказа отвечает на вопрос «сколько покупателю позволено думать», а не «как + быстро заметить потерянный колбэк». На практике это дало покупателю 30 минут ожидания Фишек при + первом же сбое доставки. Порог развязан: проверяем заказы старше **минуты**, на каждом тике жнеца. + Ограниченность сохраняется — запросов на заказ не больше, чем время его жизни, делённое на шаг + жнеца. - **D50. Возвраты на direct-рельсе — через API ЮKassa, одним действием.** Кнопка возврата в `/_gm` сперва двигает деньги (`POST /v3/refunds`, `Idempotence-Key` = идентификатор заказа, с чеком возврата) и только потом пишет реверс в журнал; неудачный вызов не пишет **ничего** — журнал не diff --git a/docs/PAYMENTS_ru.md b/docs/PAYMENTS_ru.md index 2f2b387..628e222 100644 --- a/docs/PAYMENTS_ru.md +++ b/docs/PAYMENTS_ru.md @@ -254,17 +254,25 @@ durable). действовать по содержимому тела нельзя. Оно лишь называет платёж, который сервер затем перечитывает запросом `GET /v3/payments/{id}`; доверяем только этому ответу. На нём же держатся две проверки: метаданные платежа должны называть тот самый заказ, а его флаг `test` — совпадать с флагом магазина, -поэтому платёж тестового магазина никогда не начислит настоящие Фишки (и наоборот). В качестве -эшелонированной защиты — и чтобы подделыватель не превращал каждое фальшивое уведомление в наш -исходящий запрос — адрес отправителя сперва сверяется с опубликованными диапазонами ЮKassa. Ответ -сообщает провайдеру, повторять ли доставку: 200 на всё, что решено окончательно, включая дубль и -неустранимый отказ, и 5xx только на временный сбой (ЮKassa повторяет доставку 24 часа). +поэтому платёж тестового магазина никогда не начислит настоящие Фишки (и наоборот). Ответ сообщает +провайдеру, повторять ли доставку: 200 на всё, что решено окончательно, включая дубль и неустранимый +отказ, и 5xx только на временный сбой (ЮKassa повторяет доставку 24 часа). -**Сверка на истечении заказа (D49).** Постоянного опроса нет. Но безвозвратно потерянное уведомление -оставило бы деньги списанными, а Фишки — не выданными, и молча. Поэтому уже существующий жнец -просроченных заказов спрашивает у провайдера судьбу каждого заказа, который дожил до своего срока с -идентификатором платежа, и начисляет те, что на самом деле оплачены. Один запрос на заказ за всю его -жизнь. +Адрес отправителя **не проверяем** (ревизия D48). Безопасности это не добавляет — вся граница доверия +в подтверждающем чтении, — а единственное, что такая проверка давала (чтобы подделыватель не +превращал каждое фальшивое уведомление в наш исходящий запрос), уже обеспечено раньше и жёстче: заказ +находится по метаданным **до** любого обращения к провайдеру, поэтому идентификатор, которому не +соответствует ни один заказ, стоит одного чтения по индексу и на этом всё, а угадать живой order_id — +значит угадать uuid. Вдобавок проверка адреса вредна везде, где развёртывание не видит настоящих +адресов клиентов (контур за туннелем видит только свой), — там она отбивает настоящие уведомления. + +**Сверка с коротким шагом (ревизия D49).** Бесконечного опроса нет, но проверка **не привязана** к +времени жизни заказа: оно отвечает на вопрос «сколько покупателю позволено думать», а не «как быстро +мы должны заметить потерянный колбэк». Связав их, мы заставили бы покупателя ждать Фишки всё время +жизни заказа всякий раз, когда ломается доставка уведомлений. Вместо этого жнец спрашивает провайдера +о каждом незакрытом заказе старше минуты, у которого есть идентификатор платежа, и начисляет реально +оплаченные. Число запросов на один заказ при этом ограничено его временем жизни, делённым на шаг +жнеца, — единицы, — а сбой основного пути стоит минут, а не получаса. **Об отклонённой оплате сообщаем.** `payment.canceled` от ЮKassa пишет событие `failed`, поэтому покупатель узнаёт, что попытка не прошла, вместо разглядывания неменяющегося баланса. Просто diff --git a/ui/scripts/bundle-size.mjs b/ui/scripts/bundle-size.mjs index 170da9a..d97632b 100644 --- a/ui/scripts/bundle-size.mjs +++ b/ui/scripts/bundle-size.mjs @@ -46,7 +46,14 @@ const DIST = 'dist'; // rejected, since a repeated word is a real word and the board alone cannot say why the play is // refused. The rule's own logic — the played-word set and the rescoring — sits with the evaluator in // the lazy dict chunk. Scoped CSS lands in the CSS chunk, not this JS budget. -const BUDGET = { app: 131, shared: 31, landing: 5 }; +// +// The shared chunk (svelte runtime + i18n) was raised from 31 to 32 for the direct rail's email +// notice: the wallet has to explain, before the buy tap, that a money purchase needs a confirmed +// email — the server refuses the order otherwise, and a player signed in through VK or Telegram in a +// browser would otherwise meet a bare error. Every user-visible string lands here, so the chunk had +// been sitting 40 bytes under its cap; the raise restores a working margin rather than paying for +// this one string. +const BUDGET = { app: 131, shared: 32, landing: 5 }; // gzipped returns the gzipped byte size of a built asset, or 0 when the reference is not a // local file (e.g. the Telegram SDK loaded from a CDN) or is missing.