feat(payments): settle the direct rail through YooKassa #293

Merged
developer merged 6 commits from feature/yookassa-direct-rail into development 2026-07-28 10:49:47 +00:00
12 changed files with 174 additions and 155 deletions
Showing only changes of commit 4cac09c9f3 - Show all commits
+14 -5
View File
@@ -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 (E0E9). 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
+10 -6
View File
@@ -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
@@ -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) {
+18 -9
View File
@@ -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
}
+6 -6
View File
@@ -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).
+9 -12
View File
@@ -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")
-40
View File
@@ -3,7 +3,6 @@ package yookassa
import (
"encoding/json"
"fmt"
"net/netip"
)
// Notification events this integration subscribes to. An event name is "<object>.<status>": 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)
}
-40
View File
@@ -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",
+19 -9
View File
@@ -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
+19 -8
View File
@@ -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` = идентификатор заказа, с чеком
возврата) и только потом пишет реверс в журнал; неудачный вызов не пишет **ничего** — журнал не
+18 -10
View File
@@ -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`, поэтому
покупатель узнаёт, что попытка не прошла, вместо разглядывания неменяющегося баланса. Просто
+8 -1
View File
@@ -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.