package yookassa import ( "encoding/json" "fmt" "net/netip" ) // Notification events this integration subscribes to. An event name is ".": 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. Refunds here are always initiated by an // operator through the API, which records them synchronously, so this event is informational. 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 } // 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) }