feat(payments): settle the direct rail through YooKassa #293
+14
-5
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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`, поэтому
|
||||
покупатель узнаёт, что попытка не прошла, вместо разглядывания неменяющегося баланса. Просто
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user