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 - **Monetization** — «Фишка» currency, per-platform wallets, ads. Agreements in
`docs/PAYMENTS_DECISIONS_ru.md`; a phased plan (E0E9). go-jet money rule above applies. `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 - **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 confirming `GET /v3/payments/{id}` — never the notification body. Two extra guards ride on the
allowlist. Two extra guards ride on the confirmed object: its `metadata.order_id` must name the confirmed object: its `metadata.order_id` must name the order, and its `test` flag must match the
order, and its `test` flag must match the shop's `IsTest` (a test-shop payment must never credit shop's `IsTest` (a test-shop payment must never credit real chips). Answer 200 for anything
real chips). Answer 200 for anything durably decided, 5xx only to be redelivered (YooKassa retries durably decided, 5xx only to be redelivered (YooKassa retries 24h).
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 - **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 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 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 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 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`). 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 - **Notification** — `/pay/yookassa/notify` (gateway: rate limit + raw-body proxy; backend: the
allowlist, then the confirming `GetPayment`). Nothing in the body is acted on (D48). Guards: the confirming `GetPayment`). Nothing in the body is acted on (D48). Guards: the payment's metadata must
payment's metadata must name the order, and its `test` flag must match the shop's. `payment.canceled` name the order, and its `test` flag must match the shop's. The sender address is deliberately not
records a `failed` event. 200 = durably decided, 5xx = redeliver. checked (D48 rev) — it adds no security, and the order lookup that precedes any provider call is
- **Reconcile** — `runOrderReaper` asks the provider about each pending order that reached its expiry the tighter guard against a forger driving traffic at the provider through us.
age carrying a payment id, and credits the ones really paid (D49). One request per order. `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** — 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 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 nothing, and pressing again is safe. `refund.succeeded` is handled too (D52), because the merchant
@@ -41,6 +41,9 @@ type fakeYooKassa struct {
refundStatus string refundStatus string
// refundCalls counts the refunds actually requested. // refundCalls counts the refunds actually requested.
refundCalls int 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 answers GET /refunds/{id}; a missing id is a 404.
refunds map[string]yookassa.Refund refunds map[string]yookassa.Refund
// createdIdempotenceKey records the key sent on the last create-payment call. // 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 f.payments[id] = p
_ = json.NewEncoder(w).Encode(p) _ = json.NewEncoder(w).Encode(p)
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/payments/"): case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/payments/"):
f.paymentGets++
p, ok := f.payments[strings.TrimPrefix(r.URL.Path, "/payments/")] p, ok := f.payments[strings.TrimPrefix(r.URL.Path, "/payments/")]
if !ok { if !ok {
w.WriteHeader(http.StatusNotFound) w.WriteHeader(http.StatusNotFound)
@@ -267,19 +271,37 @@ func TestYooKassaNotifyIsNotEvidence(t *testing.T) {
} }
} }
// TestYooKassaNotifyRejectsForeignSenders checks the sender allowlist: an address outside YooKassa's // TestYooKassaNotifyForAnUnknownOrderCostsNoProviderCall pins what actually keeps a forger from
// published ranges is refused before any provider call, so a forger cannot make us fan out requests. // using this endpoint to drive traffic at the provider on our behalf. The sender address is not
func TestYooKassaNotifyRejectsForeignSenders(t *testing.T) { // 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) f, shop := newFakeYooKassa(t)
srv, pay := yookassaServer(t, shop) srv, pay := yookassaServer(t, shop)
acc := provisionAccount(t) acc := provisionAccount(t)
orderID, paymentID := seedYooKassaOrder(t, f, pay, acc) orderID, paymentID := seedYooKassaOrder(t, f, pay, acc)
if code := postYooKassaNotify(t, srv, "8.8.8.8", yookassa.EventPaymentSucceeded, paymentID, orderID.String()); code != http.StatusForbidden { if code := postYooKassaNotify(t, srv, "10.77.0.1", yookassa.EventPaymentSucceeded, paymentID, orderID.String()); code != http.StatusOK {
t.Fatalf("notify from a foreign address = %d, want 403", code) t.Fatalf("notify = %d, want 200", code)
} }
if got := readBalance(t, acc, "direct"); got != 0 { if got := readBalance(t, acc, "direct"); got != 100 {
t.Errorf("balance = %d, want 0 — a foreign sender credited chips", got) 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) acc := provisionAccount(t)
orderID, _ := seedYooKassaOrder(t, f, pay, acc) 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(), 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) t.Fatalf("age order: %v", err)
} }
if n := srv.ReconcileYooKassaOrders(context.Background()); n != 1 { 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 // TestYooKassaReconcileLeavesUnpaidOrders checks the sweep does not invent a credit for an order the
// customer abandoned. // customer abandoned.
func TestYooKassaReconcileLeavesUnpaidOrders(t *testing.T) { func TestYooKassaReconcileLeavesUnpaidOrders(t *testing.T) {
+18 -9
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"time"
"github.com/google/uuid" "github.com/google/uuid"
) )
@@ -127,16 +128,24 @@ func (s *Service) OrderByProviderPayment(ctx context.Context, provider, provider
// run of provider calls. // run of provider calls.
const reconcileBatch = 50 const reconcileBatch = 50
// PendingForReconcile returns the pending orders that have reached their expiry age while carrying a // ReconcileAfter is how old a pending order must be before the provider is asked what became of it.
// provider payment id — the ones where the money may well have moved but no callback ever told us. // It is deliberately NOT the order lifetime: that governs when an unpaid order is written off, which
// The caller asks the provider for each one's real outcome before ExpireOrders writes them off. // answers "how long may a customer take to pay", a different question from "how soon should we
// Orders that never reached a payment are not returned: there is nothing to ask about. // 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) { func (s *Service) PendingForReconcile(ctx context.Context) ([]OrderRef, error) {
ttl, err := s.store.orderTTL(ctx) rows, err := s.store.pendingForReconcile(ctx, int(ReconcileAfter.Seconds()), s.clock(), reconcileBatch)
if err != nil {
return nil, err
}
rows, err := s.store.pendingForReconcile(ctx, ttl, s.clock(), reconcileBatch)
if err != nil { if err != nil {
return nil, err return nil, err
} }
+6 -6
View File
@@ -268,12 +268,12 @@ func (s *Store) attachProviderPayment(ctx context.Context, orderID uuid.UUID, pr
return nil return nil
} }
// pendingForReconcile reads the pending orders that have reached their expiry age and carry a // pendingForReconcile reads the pending orders older than ageSeconds that carry a provider payment
// provider payment id — the ones whose real outcome is still unknown to us because no callback ever // id — the ones whose real outcome is still unknown to us because no callback ever arrived. The
// arrived. The caller asks the provider what happened before the order is written off as expired. // caller asks the provider what happened. Orders with no provider payment id are skipped: the
// Orders with no provider payment id are skipped: the customer never got as far as a payment. // customer never got as far as a payment.
func (s *Store) pendingForReconcile(ctx context.Context, ttlSeconds int, now time.Time, limit int) ([]orderRow, error) { func (s *Store) pendingForReconcile(ctx context.Context, ageSeconds int, now time.Time, limit int) ([]orderRow, error) {
cutoff := now.Add(-time.Duration(ttlSeconds) * time.Second) cutoff := now.Add(-time.Duration(ageSeconds) * time.Second)
var rows []model.Orders var rows []model.Orders
err := postgres.SELECT(table.Orders.AllColumns). err := postgres.SELECT(table.Orders.AllColumns).
FROM(table.Orders). 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 // 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. // 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 // 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 // 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, // failure we want redelivered (YooKassa retries for 24 hours).
// 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.
func (s *Server) handleYooKassaNotify(c *gin.Context) { 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)) raw, err := io.ReadAll(io.LimitReader(c.Request.Body, maxNotifyBytes))
if err != nil { if err != nil {
c.String(http.StatusInternalServerError, "error") c.String(http.StatusInternalServerError, "error")
-40
View File
@@ -3,7 +3,6 @@ package yookassa
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/netip"
) )
// Notification events this integration subscribes to. An event name is "<object>.<status>": the // 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 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) { func TestParseRefundNotification(t *testing.T) {
n, err := ParseNotification([]byte(`{"type":"notification","event":"refund.succeeded", n, err := ParseNotification([]byte(`{"type":"notification","event":"refund.succeeded",
"object":{"id":"refund-1","status":"succeeded","payment_id":"pay-1", "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 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 `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 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 can never credit real chips (nor a live payment be credited against test credentials). The reply
depth — and to stop a forger turning each fabricated notification into an outbound call of ours — the tells the provider whether to redeliver: 200 for anything durably decided, including a duplicate and
sender address is first checked against YooKassa's published ranges. The reply tells the provider a permanent rejection, and 5xx only for a transient failure (YooKassa redelivers for 24 hours).
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 The sender address is **not** checked (D48 rev). It would add no security — the confirming read is
would leave the money taken and the chips unowed, silently, so the existing pending-order reaper asks the whole boundary — and the one thing it bought, keeping a forger from turning each fabricated
the provider what became of each order that reached its expiry age carrying a payment id, and credits notification into an outbound call of ours, is already bought earlier and far more tightly: the order
the ones that were in fact paid. One request per order over its whole life. 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, **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 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. Дополнительно: метаданные `GET /v3/payments/{id}`, и действует **исключительно** по ответу API. Дополнительно: метаданные
платежа должны называть тот самый заказ; флаг `test` платежа должен совпадать с флагом магазина платежа должны называть тот самый заказ; флаг `test` платежа должен совпадать с флагом магазина
(платёж тестового магазина не начислит настоящие Фишки, и наоборот); адрес отправителя сверяется с (платёж тестового магазина не начислит настоящие Фишки, и наоборот). Ответ провайдеру: 200 на всё
опубликованными диапазонами ЮKassa — эшелонированная защита, которая не даёт превратить каждое окончательно решённое (включая дубль и неустранимый отказ), 5xx только на временный сбой (ЮKassa
фальшивое уведомление в наш исходящий запрос. Ответ провайдеру: 200 на всё окончательно решённое повторяет 24 часа).
(включая дубль и неустранимый отказ), 5xx только на временный сбой (ЮKassa повторяет 24 часа). **Ревизия: адрес отправителя не проверяем.** Изначально сверяли с опубликованными диапазонами
- **D49. Сверка — одна проверка на истечении заказа, без постоянного опроса.** «Или» в документации ЮKassa как эшелонированную защиту. Оказалось лишним и вредным: единственное, что она давала —
не дать подделывателю превратить фальшивое уведомление в наш исходящий запрос — уже обеспечено
раньше и жёстче, потому что заказ ищется по метаданным ДО обращения к провайдеру (несуществующий
order_id стоит одного чтения по индексу; угадать живой — значит угадать uuid). А на тестовом
контуре, который за туннелем видит только свой внутренний адрес, проверка отбивала настоящие
уведомления — ровно как IP-баны, сделанные в репозитории prod-only по той же причине.
- **D49. Сверка — без постоянного опроса, но с коротким шагом (ревизия).** «Или» в документации
ЮKassa адресовано тем, кто не хочет вебхуки; у нас вебхуки основные. Но безвозвратно потерянное ЮKassa адресовано тем, кто не хочет вебхуки; у нас вебхуки основные. Но безвозвратно потерянное
уведомление оставило бы деньги списанными, а Фишки — не выданными, и молча. Поэтому существующий уведомление оставило бы деньги списанными, а Фишки — не выданными, и молча. Поэтому существующий
жнец просроченных заказов перед списанием в `expired` спрашивает провайдера о судьбе каждого заказа, жнец спрашивает провайдера о судьбе незакрытых заказов с идентификатором платежа и начисляет реально
дожившего до срока с идентификатором платежа, и начисляет реально оплаченные. Один запрос на заказ оплаченные; отдельный воркер не заводим.
за всю его жизнь; отдельный воркер не заводим. **Ревизия порога:** сперва проверка была привязана к возрасту истечения заказа (30 минут), и это
была ошибка — время жизни заказа отвечает на вопрос «сколько покупателю позволено думать», а не «как
быстро заметить потерянный колбэк». На практике это дало покупателю 30 минут ожидания Фишек при
первом же сбое доставки. Порог развязан: проверяем заказы старше **минуты**, на каждом тике жнеца.
Ограниченность сохраняется — запросов на заказ не больше, чем время его жизни, делённое на шаг
жнеца.
- **D50. Возвраты на direct-рельсе — через API ЮKassa, одним действием.** Кнопка возврата в `/_gm` - **D50. Возвраты на direct-рельсе — через API ЮKassa, одним действием.** Кнопка возврата в `/_gm`
сперва двигает деньги (`POST /v3/refunds`, `Idempotence-Key` = идентификатор заказа, с чеком сперва двигает деньги (`POST /v3/refunds`, `Idempotence-Key` = идентификатор заказа, с чеком
возврата) и только потом пишет реверс в журнал; неудачный вызов не пишет **ничего** — журнал не возврата) и только потом пишет реверс в журнал; неудачный вызов не пишет **ничего** — журнал не
+18 -10
View File
@@ -254,17 +254,25 @@ durable).
действовать по содержимому тела нельзя. Оно лишь называет платёж, который сервер затем перечитывает действовать по содержимому тела нельзя. Оно лишь называет платёж, который сервер затем перечитывает
запросом `GET /v3/payments/{id}`; доверяем только этому ответу. На нём же держатся две проверки: запросом `GET /v3/payments/{id}`; доверяем только этому ответу. На нём же держатся две проверки:
метаданные платежа должны называть тот самый заказ, а его флаг `test` — совпадать с флагом магазина, метаданные платежа должны называть тот самый заказ, а его флаг `test` — совпадать с флагом магазина,
поэтому платёж тестового магазина никогда не начислит настоящие Фишки (и наоборот). В качестве поэтому платёж тестового магазина никогда не начислит настоящие Фишки (и наоборот). Ответ сообщает
эшелонированной защиты — и чтобы подделыватель не превращал каждое фальшивое уведомление в наш провайдеру, повторять ли доставку: 200 на всё, что решено окончательно, включая дубль и неустранимый
исходящий запрос — адрес отправителя сперва сверяется с опубликованными диапазонами ЮKassa. Ответ отказ, и 5xx только на временный сбой (ЮKassa повторяет доставку 24 часа).
сообщает провайдеру, повторять ли доставку: 200 на всё, что решено окончательно, включая дубль и
неустранимый отказ, и 5xx только на временный сбой (ЮKassa повторяет доставку 24 часа).
**Сверка на истечении заказа (D49).** Постоянного опроса нет. Но безвозвратно потерянное уведомление Адрес отправителя **не проверяем** (ревизия D48). Безопасности это не добавляет — вся граница доверия
оставило бы деньги списанными, а Фишки — не выданными, и молча. Поэтому уже существующий жнец в подтверждающем чтении, — а единственное, что такая проверка давала (чтобы подделыватель не
просроченных заказов спрашивает у провайдера судьбу каждого заказа, который дожил до своего срока с превращал каждое фальшивое уведомление в наш исходящий запрос), уже обеспечено раньше и жёстче: заказ
идентификатором платежа, и начисляет те, что на самом деле оплачены. Один запрос на заказ за всю его находится по метаданным **до** любого обращения к провайдеру, поэтому идентификатор, которому не
жизнь. соответствует ни один заказ, стоит одного чтения по индексу и на этом всё, а угадать живой order_id —
значит угадать uuid. Вдобавок проверка адреса вредна везде, где развёртывание не видит настоящих
адресов клиентов (контур за туннелем видит только свой), — там она отбивает настоящие уведомления.
**Сверка с коротким шагом (ревизия D49).** Бесконечного опроса нет, но проверка **не привязана** к
времени жизни заказа: оно отвечает на вопрос «сколько покупателю позволено думать», а не «как быстро
мы должны заметить потерянный колбэк». Связав их, мы заставили бы покупателя ждать Фишки всё время
жизни заказа всякий раз, когда ломается доставка уведомлений. Вместо этого жнец спрашивает провайдера
о каждом незакрытом заказе старше минуты, у которого есть идентификатор платежа, и начисляет реально
оплаченные. Число запросов на один заказ при этом ограничено его временем жизни, делённым на шаг
жнеца, — единицы, — а сбой основного пути стоит минут, а не получаса.
**Об отклонённой оплате сообщаем.** `payment.canceled` от ЮKassa пишет событие `failed`, поэтому **Об отклонённой оплате сообщаем.** `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 // 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 // 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. // 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 // 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. // local file (e.g. the Telegram SDK loaded from a CDN) or is missing.