From 1528faeea8f2d11eb7a7e9ab62e895bb24fb500e Mon Sep 17 00:00:00 2001 From: Andrew McKnight Date: Wed, 10 Jun 2026 16:40:23 -0800 Subject: [PATCH 1/3] feat(billing-platform): add refund types + record_charge_refunds/handle_charge_refunded endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the proto contracts the getsentry ``charge.refunded`` webhook path will need (REVENG-157): - ``common/v1/stripe_charge.proto``: new ``StripeRefund`` message + ``repeated refunds`` field on ``StripeCharge`` so webhook handlers receive per-refund metadata (id, amount, reason) and can record refunds idempotently — the aggregate ``amount_refunded`` on the charge alone isn't enough. - ``services/charge/v1/charge.proto``: new ``PlatformRefund`` canonical projection, paralleling ``PlatformCharge``. The shared response type for every charge-service refund endpoint. - ``services/charge/v1/endpoint_record_charge_refunds.proto``: ``ChargeService.record_charge_refunds`` — records per-refund rows from a Stripe webhook payload idempotently (keyed on refund stripe_id) and syncs the aggregate ``amount_refunded`` / ``refunded`` state. - ``services/invoicer/v1/endpoint_handle_charge_refunded.proto``: ``InvoicerService.handle_charge_refunded`` — presentation handler that the webhook layer calls; delegates to ``record_charge_refunds`` and returns ``handled=False`` when the charge isn't platform so the caller falls through to legacy. The other two refund endpoints (``refund_charge``, ``list_refunds_by_invoice``) are split into separate PRs paired with their getsentry consumers. Rust bindings + Cargo.lock + python bindings regen are handled by CI. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../billing/v1/common/v1/stripe_charge.proto | 18 +++++++++++++ .../v1/services/charge/v1/charge.proto | 18 +++++++++++++ .../v1/endpoint_record_charge_refunds.proto | 26 +++++++++++++++++++ .../v1/endpoint_handle_charge_refunded.proto | 20 ++++++++++++++ 4 files changed, 82 insertions(+) create mode 100644 proto/sentry_protos/billing/v1/services/charge/v1/endpoint_record_charge_refunds.proto create mode 100644 proto/sentry_protos/billing/v1/services/invoicer/v1/endpoint_handle_charge_refunded.proto diff --git a/proto/sentry_protos/billing/v1/common/v1/stripe_charge.proto b/proto/sentry_protos/billing/v1/common/v1/stripe_charge.proto index 1c527650..ceafdf45 100644 --- a/proto/sentry_protos/billing/v1/common/v1/stripe_charge.proto +++ b/proto/sentry_protos/billing/v1/common/v1/stripe_charge.proto @@ -14,6 +14,20 @@ message PaymentMethodDetails { } } +// A snapshot of a single Stripe refund attached to a charge. Conveys +// per-refund metadata so handlers can record refunds individually and +// dedupe by ``id``; the aggregate ``amount_refunded`` on ``StripeCharge`` +// alone is not enough for idempotent ingestion. +message StripeRefund { + // Stripe id of the refund (e.g. "re_xxx"). + string id = 1; + // Refund amount in the charge's smallest currency unit (cents for USD). + uint64 amount = 2; + // Stripe-supplied reason (e.g. "requested_by_customer", "duplicate", + // "fraudulent"). Unset when Stripe did not provide one. + optional string reason = 3; +} + // A snapshot of a Stripe charge object. Used as the payload when reacting // to Stripe webhook events. message StripeCharge { @@ -26,4 +40,8 @@ message StripeCharge { int64 created_st = 6; optional string failure_code = 7; PaymentMethodDetails payment_method_details = 8; + // Per-refund records attached to this charge. Empty when the charge has + // no refunds. The list reflects the state of refunds at the time the + // webhook was emitted. + repeated StripeRefund refunds = 9; } diff --git a/proto/sentry_protos/billing/v1/services/charge/v1/charge.proto b/proto/sentry_protos/billing/v1/services/charge/v1/charge.proto index 4c355d5e..bd75ca59 100644 --- a/proto/sentry_protos/billing/v1/services/charge/v1/charge.proto +++ b/proto/sentry_protos/billing/v1/services/charge/v1/charge.proto @@ -18,3 +18,21 @@ message PlatformCharge { uint64 amount_refunded = 8; optional string card_last_4 = 9; } + +// Canonical projection of a stored platform refund. One row per recorded +// refund against a ``PlatformCharge``; the aggregate ``amount_refunded`` on +// ``PlatformCharge`` is a cache of the sum of these. +message PlatformRefund { + // Stripe id of the refund (e.g. "re_xxx"). + string stripe_id = 1; + // Stripe id of the charge this refund is against (e.g. "ch_xxx"). Joins + // back to ``PlatformCharge.stripe_id``. + string charge_stripe_id = 2; + uint64 organization_id = 3; + // Refund amount in cents. + uint64 amount = 4; + // Stripe-supplied refund reason. Unset when Stripe did not provide one. + optional string reason = 5; + // Unix epoch seconds when the refund was recorded by the platform. + int64 date_added_st = 6; +} diff --git a/proto/sentry_protos/billing/v1/services/charge/v1/endpoint_record_charge_refunds.proto b/proto/sentry_protos/billing/v1/services/charge/v1/endpoint_record_charge_refunds.proto new file mode 100644 index 00000000..45e65724 --- /dev/null +++ b/proto/sentry_protos/billing/v1/services/charge/v1/endpoint_record_charge_refunds.proto @@ -0,0 +1,26 @@ +syntax = "proto3"; + +package sentry_protos.billing.v1.services.charge.v1; + +import "sentry_protos/billing/v1/common/v1/stripe_charge.proto"; +import "sentry_protos/billing/v1/services/charge/v1/charge.proto"; + +// Records platform refunds for a Stripe charge from a webhook payload. +// Mirrors the contents of ``stripe_charge.refunds`` as ``PlatformRefund`` +// rows idempotently keyed by Stripe refund id, and syncs the aggregate +// ``amount_refunded`` / ``refunded`` state on the stored ``PlatformCharge``. +// Called by the presentation layer that owns the ``charge.refunded`` +// webhook handler; the charge service does not call Stripe in this path +// (Stripe initiated the refund). +message RecordChargeRefundsRequest { + sentry_protos.billing.v1.common.v1.StripeCharge stripe_charge = 1; +} + +message RecordChargeRefundsResponse { + // Unset when no platform charge exists for ``stripe_charge.id``. Callers + // use this to distinguish platform charges from legacy charges. + optional PlatformCharge charge = 1; + // The platform refund rows that were recorded or already existed, + // ordered by ``date_added_st`` ascending. + repeated PlatformRefund refunds = 2; +} diff --git a/proto/sentry_protos/billing/v1/services/invoicer/v1/endpoint_handle_charge_refunded.proto b/proto/sentry_protos/billing/v1/services/invoicer/v1/endpoint_handle_charge_refunded.proto new file mode 100644 index 00000000..29d27bf2 --- /dev/null +++ b/proto/sentry_protos/billing/v1/services/invoicer/v1/endpoint_handle_charge_refunded.proto @@ -0,0 +1,20 @@ +syntax = "proto3"; + +package sentry_protos.billing.v1.services.invoicer.v1; + +import "sentry_protos/billing/v1/common/v1/stripe_charge.proto"; + +// Request to react to a Stripe `charge.refunded` webhook event for a +// charge created by the billing platform. The included ``stripe_charge`` +// carries the per-refund records on its ``refunds`` field; the handler +// records each refund idempotently by Stripe refund id and syncs the +// aggregate refund state on the stored platform charge. +message HandleChargeRefundedRequest { + sentry_protos.billing.v1.common.v1.StripeCharge stripe_charge = 1; +} + +message HandleChargeRefundedResponse { + // True when the charge was created by the billing platform and the + // service has finished its handling. + bool handled = 1; +} From a4a5dfa18aecd6f378dec28c4955f2fb2393c95a Mon Sep 17 00:00:00 2001 From: Andrew McKnight Date: Wed, 10 Jun 2026 16:40:35 -0800 Subject: [PATCH 2/3] feat(billing-platform): add ChargeService.refund_charge endpoint Adds the proto contract for admin-initiated refunds against a platform charge (REVENG-157). The endpoint takes a Stripe charge id + amount, calls Stripe to create the refund, records a ``PlatformRefund`` row, and enforces an over-refund guard so cumulative refunds against a single charge can't exceed the charge total. Builds on the ``PlatformRefund`` message added in the previous PR in this proto stack. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../charge/v1/endpoint_refund_charge.proto | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 proto/sentry_protos/billing/v1/services/charge/v1/endpoint_refund_charge.proto diff --git a/proto/sentry_protos/billing/v1/services/charge/v1/endpoint_refund_charge.proto b/proto/sentry_protos/billing/v1/services/charge/v1/endpoint_refund_charge.proto new file mode 100644 index 00000000..4665ce2e --- /dev/null +++ b/proto/sentry_protos/billing/v1/services/charge/v1/endpoint_refund_charge.proto @@ -0,0 +1,28 @@ +syntax = "proto3"; + +package sentry_protos.billing.v1.services.charge.v1; + +import "sentry_protos/billing/v1/services/charge/v1/charge.proto"; + +// Issues a refund against a platform charge by calling Stripe and +// recording a ``PlatformRefund`` row. Caller supplies the partial-refund +// amount; the service enforces the over-refund guard (sum of recorded +// refunds + new amount <= charge amount) under optimistic locking to +// prevent concurrent double refunds. +message RefundChargeRequest { + // Stripe id of the charge to refund (e.g. "ch_xxx"). Identifies the + // ``PlatformCharge`` to refund against. + string stripe_charge_id = 1; + // Refund amount in cents. Must be > 0 and <= remaining refundable amount + // on the charge. + uint64 amount = 2; + // Optional reason recorded with the refund and forwarded to Stripe. + optional string reason = 3; +} + +message RefundChargeResponse { + // The refund record created by this call. + PlatformRefund refund = 1; + // The charge with refund aggregates synced. + PlatformCharge charge = 2; +} From 3f3b6c7eace01793bfe1ffe61a845959aad6e97b Mon Sep 17 00:00:00 2001 From: "getsantry[bot]" <66042841+getsantry[bot]@users.noreply.github.com> Date: Thu, 11 Jun 2026 04:32:22 +0000 Subject: [PATCH 3/3] chore: Regenerate Rust bindings --- Cargo.lock | 2 +- .../src/sentry_protos.billing.v1.common.v1.rs | 24 +++++- ...ry_protos.billing.v1.services.charge.v1.rs | 79 ++++++++++++++++++- ..._protos.billing.v1.services.invoicer.v1.rs | 21 ++++- 4 files changed, 122 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5847e137..8eaa8294 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -717,7 +717,7 @@ checksum = "955d28af4278de8121b7ebeb796b6a45735dc01436d898801014aced2773a3d6" [[package]] name = "sentry_protos" -version = "0.27.0" +version = "0.27.1" dependencies = [ "prost", "prost-types", diff --git a/rust/src/sentry_protos.billing.v1.common.v1.rs b/rust/src/sentry_protos.billing.v1.common.v1.rs index fdceb88c..48877d60 100644 --- a/rust/src/sentry_protos.billing.v1.common.v1.rs +++ b/rust/src/sentry_protos.billing.v1.common.v1.rs @@ -384,9 +384,26 @@ pub mod payment_method_details { Card(super::Card), } } +/// A snapshot of a single Stripe refund attached to a charge. Conveys +/// per-refund metadata so handlers can record refunds individually and +/// dedupe by `id`; the aggregate `amount_refunded` on `StripeCharge` +/// alone is not enough for idempotent ingestion. +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct StripeRefund { + /// Stripe id of the refund (e.g. "re_xxx"). + #[prost(string, tag = "1")] + pub id: ::prost::alloc::string::String, + /// Refund amount in the charge's smallest currency unit (cents for USD). + #[prost(uint64, tag = "2")] + pub amount: u64, + /// Stripe-supplied reason (e.g. "requested_by_customer", "duplicate", + /// "fraudulent"). Unset when Stripe did not provide one. + #[prost(string, optional, tag = "3")] + pub reason: ::core::option::Option<::prost::alloc::string::String>, +} /// A snapshot of a Stripe charge object. Used as the payload when reacting /// to Stripe webhook events. -#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +#[derive(Clone, PartialEq, ::prost::Message)] pub struct StripeCharge { #[prost(string, tag = "1")] pub id: ::prost::alloc::string::String, @@ -405,4 +422,9 @@ pub struct StripeCharge { pub failure_code: ::core::option::Option<::prost::alloc::string::String>, #[prost(message, optional, tag = "8")] pub payment_method_details: ::core::option::Option, + /// Per-refund records attached to this charge. Empty when the charge has + /// no refunds. The list reflects the state of refunds at the time the + /// webhook was emitted. + #[prost(message, repeated, tag = "9")] + pub refunds: ::prost::alloc::vec::Vec, } diff --git a/rust/src/sentry_protos.billing.v1.services.charge.v1.rs b/rust/src/sentry_protos.billing.v1.services.charge.v1.rs index d4beade9..89f06d9d 100644 --- a/rust/src/sentry_protos.billing.v1.services.charge.v1.rs +++ b/rust/src/sentry_protos.billing.v1.services.charge.v1.rs @@ -25,6 +25,30 @@ pub struct PlatformCharge { #[prost(string, optional, tag = "9")] pub card_last_4: ::core::option::Option<::prost::alloc::string::String>, } +/// Canonical projection of a stored platform refund. One row per recorded +/// refund against a `PlatformCharge`; the aggregate `amount_refunded` on +/// `PlatformCharge` is a cache of the sum of these. +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct PlatformRefund { + /// Stripe id of the refund (e.g. "re_xxx"). + #[prost(string, tag = "1")] + pub stripe_id: ::prost::alloc::string::String, + /// Stripe id of the charge this refund is against (e.g. "ch_xxx"). Joins + /// back to `PlatformCharge.stripe_id`. + #[prost(string, tag = "2")] + pub charge_stripe_id: ::prost::alloc::string::String, + #[prost(uint64, tag = "3")] + pub organization_id: u64, + /// Refund amount in cents. + #[prost(uint64, tag = "4")] + pub amount: u64, + /// Stripe-supplied refund reason. Unset when Stripe did not provide one. + #[prost(string, optional, tag = "5")] + pub reason: ::core::option::Option<::prost::alloc::string::String>, + /// Unix epoch seconds when the refund was recorded by the platform. + #[prost(int64, tag = "6")] + pub date_added_st: i64, +} #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct CaptureChargeRequest { #[prost(enumeration = "ChargeMethod", tag = "1")] @@ -126,11 +150,64 @@ pub struct ListChargesForInvoiceResponse { #[prost(message, repeated, tag = "1")] pub charges: ::prost::alloc::vec::Vec, } +/// Records platform refunds for a Stripe charge from a webhook payload. +/// Mirrors the contents of `stripe_charge.refunds` as `PlatformRefund` +/// rows idempotently keyed by Stripe refund id, and syncs the aggregate +/// `amount_refunded` / `refunded` state on the stored `PlatformCharge`. +/// Called by the presentation layer that owns the `charge.refunded` +/// webhook handler; the charge service does not call Stripe in this path +/// (Stripe initiated the refund). +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct RecordChargeRefundsRequest { + #[prost(message, optional, tag = "1")] + pub stripe_charge: ::core::option::Option< + super::super::super::common::v1::StripeCharge, + >, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct RecordChargeRefundsResponse { + /// Unset when no platform charge exists for `stripe_charge.id`. Callers + /// use this to distinguish platform charges from legacy charges. + #[prost(message, optional, tag = "1")] + pub charge: ::core::option::Option, + /// The platform refund rows that were recorded or already existed, + /// ordered by `date_added_st` ascending. + #[prost(message, repeated, tag = "2")] + pub refunds: ::prost::alloc::vec::Vec, +} +/// Issues a refund against a platform charge by calling Stripe and +/// recording a `PlatformRefund` row. Caller supplies the partial-refund +/// amount; the service enforces the over-refund guard (sum of recorded +/// refunds + new amount \<= charge amount) under optimistic locking to +/// prevent concurrent double refunds. +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct RefundChargeRequest { + /// Stripe id of the charge to refund (e.g. "ch_xxx"). Identifies the + /// `PlatformCharge` to refund against. + #[prost(string, tag = "1")] + pub stripe_charge_id: ::prost::alloc::string::String, + /// Refund amount in cents. Must be > 0 and \<= remaining refundable amount + /// on the charge. + #[prost(uint64, tag = "2")] + pub amount: u64, + /// Optional reason recorded with the refund and forwarded to Stripe. + #[prost(string, optional, tag = "3")] + pub reason: ::core::option::Option<::prost::alloc::string::String>, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct RefundChargeResponse { + /// The refund record created by this call. + #[prost(message, optional, tag = "1")] + pub refund: ::core::option::Option, + /// The charge with refund aggregates synced. + #[prost(message, optional, tag = "2")] + pub charge: ::core::option::Option, +} /// Synchronizes a stored platform charge with the latest snapshot from /// Stripe. The charge is identified by `stripe_charge.id`. Fields like /// `paid`, `failure_code` and refund state are copied onto the stored /// record so the database reflects the current payment-provider state. -#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +#[derive(Clone, PartialEq, ::prost::Message)] pub struct UpdateChargeRequest { #[prost(message, optional, tag = "1")] pub stripe_charge: ::core::option::Option< diff --git a/rust/src/sentry_protos.billing.v1.services.invoicer.v1.rs b/rust/src/sentry_protos.billing.v1.services.invoicer.v1.rs index fc2bc926..be76f95e 100644 --- a/rust/src/sentry_protos.billing.v1.services.invoicer.v1.rs +++ b/rust/src/sentry_protos.billing.v1.services.invoicer.v1.rs @@ -110,9 +110,28 @@ pub struct HandleChargeDisputedResponse { #[prost(bool, tag = "1")] pub handled: bool, } +/// Request to react to a Stripe `charge.refunded` webhook event for a +/// charge created by the billing platform. The included `stripe_charge` +/// carries the per-refund records on its `refunds` field; the handler +/// records each refund idempotently by Stripe refund id and syncs the +/// aggregate refund state on the stored platform charge. +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct HandleChargeRefundedRequest { + #[prost(message, optional, tag = "1")] + pub stripe_charge: ::core::option::Option< + super::super::super::common::v1::StripeCharge, + >, +} +#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] +pub struct HandleChargeRefundedResponse { + /// True when the charge was created by the billing platform and the + /// service has finished its handling. + #[prost(bool, tag = "1")] + pub handled: bool, +} /// Request to react to a Stripe `charge.succeeded` webhook event for a /// charge created by the billing platform. -#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +#[derive(Clone, PartialEq, ::prost::Message)] pub struct HandleChargeSucceededRequest { #[prost(message, optional, tag = "1")] pub stripe_charge: ::core::option::Option<