395a307eca
CI / changes (pull_request) Successful in 11s
CI / unit (pull_request) Successful in 22s
CI / integration (pull_request) Successful in 29s
CI / ui (pull_request) Successful in 1m27s
CI / conformance (pull_request) Successful in 19s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m52s
Two holes on the refund path, both found by asking what happens when a refund does not come from our own `/_gm` button. The merchant cabinet is a second entry point. An operator can refund there, and such a refund never passes through our API — so the money went back while the chips stayed credited, silently. Handle `refund.succeeded`: the refund is re-read from the API (the notification body is no more evidence here than it is for a payment), bound back to its order through the payment id recorded when the payment was minted, and reversed through the same engine. It is idempotent on (provider, refund id), so the event for a refund the console already recorded reverses nothing twice. The reversal engine is full-refund-only by design — it revokes exactly what the pack funded and rejects any other amount — so a partial refund is recorded as nothing at all and logged loudly for an operator. There is no non-arbitrary way to decide how many chips a part-refund costs, and guessing would be worse than asking a human. Second hole: a refund can still be canceled while pending, and the ledger is append-only. Recording on any non-empty refund id therefore risked revoking a customer's chips for money that stayed with us, with no way to take the row back. The console now records only a `succeeded` refund and tells the operator to press again otherwise — the idempotency key returns the same refund rather than paying twice. Tests: unit (GetRefund, the refund notification envelope, a non-final status surfaced to the caller); integration (a cabinet refund is reversed once and a redelivery is a no-op, the event after a console refund changes nothing, a partial refund records nothing, an unconfirmed refund reverses nothing, a pending refund records nothing until it settles and then does). The suite shares one database and the ledger dedupes refunds globally, so the fake provider now mints a refund id per payment — a constant id made one test's refund look like another's duplicate. Decisions D50 (amended) and D52; the notification subscription list in the deploy docs gains refund.succeeded.
113 lines
4.3 KiB
Go
113 lines
4.3 KiB
Go
package yookassa
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/netip"
|
|
)
|
|
|
|
// Notification events this integration subscribes to. An event name is "<object>.<status>": the
|
|
// object whose status changed and the status it entered.
|
|
const (
|
|
// EventPaymentSucceeded means the money was taken and the order may be credited.
|
|
EventPaymentSucceeded = "payment.succeeded"
|
|
// EventPaymentCanceled means the payment was actively declined or abandoned; nothing is credited
|
|
// and the payer is told the attempt failed.
|
|
EventPaymentCanceled = "payment.canceled"
|
|
// EventRefundSucceeded reports a completed refund. It matters because the merchant cabinet is a
|
|
// second way to issue one: a refund made there never passes through our API, so without this
|
|
// event the money would go back while the chips stayed credited.
|
|
EventRefundSucceeded = "refund.succeeded"
|
|
)
|
|
|
|
// notificationType is the fixed value of a notification envelope's type field.
|
|
const notificationType = "notification"
|
|
|
|
// Notification is an incoming webhook envelope. Object is left raw because its shape depends on the
|
|
// event — a payment for payment.*, a refund for refund.* — and because nothing in it may be acted on
|
|
// before GetPayment confirms it: YooKassa does not sign notifications.
|
|
type Notification struct {
|
|
Type string `json:"type"`
|
|
Event string `json:"event"`
|
|
Object json.RawMessage `json:"object"`
|
|
}
|
|
|
|
// ParseNotification decodes a webhook body and checks the envelope is a notification with an event.
|
|
// It deliberately validates nothing else: the body is untrusted input whose only job is to name an
|
|
// object to re-read from the API.
|
|
func ParseNotification(body []byte) (Notification, error) {
|
|
var n Notification
|
|
if err := json.Unmarshal(body, &n); err != nil {
|
|
return Notification{}, fmt.Errorf("yookassa: decode notification: %w", err)
|
|
}
|
|
if n.Type != notificationType || n.Event == "" {
|
|
return Notification{}, fmt.Errorf("yookassa: not a notification envelope (type %q, event %q)", n.Type, n.Event)
|
|
}
|
|
return n, nil
|
|
}
|
|
|
|
// Payment decodes the notification's object as a payment. Use it only for payment.* events; it
|
|
// yields the payment id to re-read, never the payment state to act on.
|
|
func (n Notification) Payment() (Payment, error) {
|
|
var p Payment
|
|
if err := json.Unmarshal(n.Object, &p); err != nil {
|
|
return Payment{}, fmt.Errorf("yookassa: decode notification payment: %w", err)
|
|
}
|
|
if p.ID == "" {
|
|
return Payment{}, fmt.Errorf("yookassa: notification payment has no id")
|
|
}
|
|
return p, nil
|
|
}
|
|
|
|
// Refund decodes the notification's object as a refund. Use it only for refund.* events; it yields
|
|
// the refund id to re-read, never the refund state to act on.
|
|
func (n Notification) Refund() (Refund, error) {
|
|
var r Refund
|
|
if err := json.Unmarshal(n.Object, &r); err != nil {
|
|
return Refund{}, fmt.Errorf("yookassa: decode notification refund: %w", err)
|
|
}
|
|
if r.ID == "" {
|
|
return Refund{}, fmt.Errorf("yookassa: notification refund has no id")
|
|
}
|
|
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)
|
|
}
|