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
|
- **Monetization** — «Фишка» currency, per-platform wallets, ads. Agreements in
|
||||||
`docs/PAYMENTS_DECISIONS_ru.md`; a phased plan (E0–E9). go-jet money rule above applies.
|
`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
|
- **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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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`, поэтому
|
||||||
покупатель узнаёт, что попытка не прошла, вместо разглядывания неменяющегося баланса. Просто
|
покупатель узнаёт, что попытка не прошла, вместо разглядывания неменяющегося баланса. Просто
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user