From 83023b02985413a9ca5f4c595cc84c5495e6df15 Mon Sep 17 00:00:00 2001 From: Andrew McKnight Date: Wed, 10 Jun 2026 16:40:23 -0800 Subject: [PATCH 1/9] feat(billing-platform): add refund types + record_charge_refunds endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the shared refund types + the charge-service endpoint driven by the `charge.refunded` webhook flow (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 across all three refund endpoints, so it ships here in the base PR. - ``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. The webhook handler in getsentry calls ``ChargeService.record_charge_refunds`` directly. We considered an ``InvoicerService.handle_charge_refunded`` wrapper (mirroring the ``charge.succeeded`` / ``charge.dispute.created`` pattern) but the wrapper would have been a passthrough -- no multi-service orchestration to coordinate, just a single call. ``charge.updated`` set the precedent for skipping the presentation layer when only one data service is involved (getsentry#20559). The other two refund endpoints (``refund_charge``, ``list_refunds_by_invoice``) are split into separate PRs paired with their getsentry consumers. Rust + Python bindings regenerated 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 +++++++++++++++++++ 3 files changed, 62 insertions(+) create mode 100644 proto/sentry_protos/billing/v1/services/charge/v1/endpoint_record_charge_refunds.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; +} From d0a9bc595b818be7bcab1e02b49156e816ee57b2 Mon Sep 17 00:00:00 2001 From: "getsantry[bot]" <66042841+getsantry[bot]@users.noreply.github.com> Date: Thu, 11 Jun 2026 05:29:56 +0000 Subject: [PATCH 2/9] chore: Regenerate Rust bindings --- Cargo.lock | 2 +- .../src/sentry_protos.billing.v1.common.v1.rs | 24 ++++++++- ...ry_protos.billing.v1.services.charge.v1.rs | 51 ++++++++++++++++++- ..._protos.billing.v1.services.invoicer.v1.rs | 2 +- 4 files changed, 75 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..269bd9ad 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,36 @@ 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, +} /// 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..83598408 100644 --- a/rust/src/sentry_protos.billing.v1.services.invoicer.v1.rs +++ b/rust/src/sentry_protos.billing.v1.services.invoicer.v1.rs @@ -112,7 +112,7 @@ pub struct HandleChargeDisputedResponse { } /// 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< From 66c0bbb505d5ff9feb3f404546deee07b99d812b Mon Sep 17 00:00:00 2001 From: Andrew McKnight Date: Thu, 11 Jun 2026 10:51:48 -0800 Subject: [PATCH 3/9] review(billing-platform): embed refunds in PlatformCharge; drop charge_stripe_id Per review feedback on #303: - ``PlatformRefund.charge_stripe_id`` removed (field 2). Nothing consumes this field today; if a future caller needs to identify the parent charge of a standalone ``PlatformRefund`` (e.g. grouping by charge in a UI), we can add it back as a new field number then. - ``PlatformCharge.refunds`` added (field 10): the recorded refund rows against the charge, ordered by ``date_added_st``. Populated by endpoints that have already loaded the refund rows; left empty by endpoints that only need the aggregate ``amount_refunded`` cache. - ``RecordChargeRefundsResponse.refunds`` removed (field 2). Refunds now live under ``response.charge.refunds`` -- the response only needs the single ``charge`` field. Matches the DB relationship (refunds belong to a specific charge) and avoids duplicating the same data at two levels of the response. Remaining ``PlatformRefund`` field numbers compacted (3..6 -> 2..5) since this message hasn't shipped to any consumer yet. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../billing/v1/services/charge/v1/charge.proto | 16 +++++++++------- .../v1/endpoint_record_charge_refunds.proto | 7 +++---- 2 files changed, 12 insertions(+), 11 deletions(-) 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 bd75ca59..77ac0102 100644 --- a/proto/sentry_protos/billing/v1/services/charge/v1/charge.proto +++ b/proto/sentry_protos/billing/v1/services/charge/v1/charge.proto @@ -17,6 +17,11 @@ message PlatformCharge { bool refunded = 7; uint64 amount_refunded = 8; optional string card_last_4 = 9; + // Recorded refunds against this charge, ordered by ``date_added_st`` + // ascending. Populated by endpoints that have already paid the cost of + // loading the refund rows (e.g. ``record_charge_refunds``); left empty + // by endpoints that only need the aggregate ``amount_refunded`` cache. + repeated PlatformRefund refunds = 10; } // Canonical projection of a stored platform refund. One row per recorded @@ -25,14 +30,11 @@ message PlatformCharge { 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; + uint64 organization_id = 2; // Refund amount in cents. - uint64 amount = 4; + uint64 amount = 3; // Stripe-supplied refund reason. Unset when Stripe did not provide one. - optional string reason = 5; + optional string reason = 4; // Unix epoch seconds when the refund was recorded by the platform. - int64 date_added_st = 6; + int64 date_added_st = 5; } 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 index 45e65724..cdcd85b0 100644 --- 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 @@ -18,9 +18,8 @@ message RecordChargeRefundsRequest { message RecordChargeRefundsResponse { // Unset when no platform charge exists for ``stripe_charge.id``. Callers - // use this to distinguish platform charges from legacy charges. + // use this to distinguish platform charges from legacy charges. When set, + // ``charge.refunds`` carries the platform refund rows that were recorded + // or already existed, ordered by ``date_added_st`` ascending. optional PlatformCharge charge = 1; - // The platform refund rows that were recorded or already existed, - // ordered by ``date_added_st`` ascending. - repeated PlatformRefund refunds = 2; } From 41ad2fc2c2a45826af5df83518a0c46e6b7bbf94 Mon Sep 17 00:00:00 2001 From: Andrew McKnight Date: Thu, 11 Jun 2026 11:33:38 -0800 Subject: [PATCH 4/9] review(billing-platform): rename PlatformRefund.amount to amount_cents Make the unit explicit on the canonical refund projection. Matches the unit-explicit naming used on the underlying Django model (``PlatformRefund.amount_cents``) and avoids consumers having to guess whether the value is dollars, cents, or something else. Field number is unchanged (3), so wire-compatible with any in-flight serialized messages from earlier #303 iterations. The sibling ``PlatformCharge.amount`` / ``StripeRefund.amount`` / ``StripeCharge.amount`` aren't renamed -- their values come from Stripe or from the legacy ``Charge`` model, where the unit-implicit naming is already a settled convention. Following up on those would be a broader audit (#REVENG-???) and out of scope for this PR. Co-Authored-By: Claude Opus 4.7 (1M context) --- proto/sentry_protos/billing/v1/services/charge/v1/charge.proto | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 77ac0102..ddd4afd4 100644 --- a/proto/sentry_protos/billing/v1/services/charge/v1/charge.proto +++ b/proto/sentry_protos/billing/v1/services/charge/v1/charge.proto @@ -32,7 +32,7 @@ message PlatformRefund { string stripe_id = 1; uint64 organization_id = 2; // Refund amount in cents. - uint64 amount = 3; + uint64 amount_cents = 3; // Stripe-supplied refund reason. Unset when Stripe did not provide one. optional string reason = 4; // Unix epoch seconds when the refund was recorded by the platform. From 53dbb75d835c77c7a73b8e0555941deceb9501bc Mon Sep 17 00:00:00 2001 From: "getsantry[bot]" <66042841+getsantry[bot]@users.noreply.github.com> Date: Thu, 11 Jun 2026 19:36:12 +0000 Subject: [PATCH 5/9] chore: Regenerate Rust bindings --- ...ry_protos.billing.v1.services.charge.v1.rs | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) 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 269bd9ad..0cad5daa 100644 --- a/rust/src/sentry_protos.billing.v1.services.charge.v1.rs +++ b/rust/src/sentry_protos.billing.v1.services.charge.v1.rs @@ -2,7 +2,7 @@ /// Canonical projection of a stored platform charge. Returned by charge /// service endpoints that need to expose the persisted charge state to /// callers. -#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +#[derive(Clone, PartialEq, ::prost::Message)] pub struct PlatformCharge { #[prost(string, tag = "1")] pub stripe_id: ::prost::alloc::string::String, @@ -24,6 +24,12 @@ pub struct PlatformCharge { pub amount_refunded: u64, #[prost(string, optional, tag = "9")] pub card_last_4: ::core::option::Option<::prost::alloc::string::String>, + /// Recorded refunds against this charge, ordered by `date_added_st` + /// ascending. Populated by endpoints that have already paid the cost of + /// loading the refund rows (e.g. `record_charge_refunds`); left empty + /// by endpoints that only need the aggregate `amount_refunded` cache. + #[prost(message, repeated, tag = "10")] + pub refunds: ::prost::alloc::vec::Vec, } /// Canonical projection of a stored platform refund. One row per recorded /// refund against a `PlatformCharge`; the aggregate `amount_refunded` on @@ -33,20 +39,16 @@ 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")] + #[prost(uint64, tag = "2")] pub organization_id: u64, /// Refund amount in cents. - #[prost(uint64, tag = "4")] - pub amount: u64, + #[prost(uint64, tag = "3")] + pub amount_cents: u64, /// Stripe-supplied refund reason. Unset when Stripe did not provide one. - #[prost(string, optional, tag = "5")] + #[prost(string, optional, tag = "4")] pub reason: ::core::option::Option<::prost::alloc::string::String>, /// Unix epoch seconds when the refund was recorded by the platform. - #[prost(int64, tag = "6")] + #[prost(int64, tag = "5")] pub date_added_st: i64, } #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] @@ -123,7 +125,7 @@ pub struct GetChargeByStripeIdRequest { #[prost(string, tag = "1")] pub stripe_id: ::prost::alloc::string::String, } -#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +#[derive(Clone, PartialEq, ::prost::Message)] pub struct GetChargeByStripeIdResponse { /// Unset when no platform charge with the given stripe_id exists. #[prost(message, optional, tag = "1")] @@ -167,13 +169,11 @@ pub struct RecordChargeRefundsRequest { #[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. + /// use this to distinguish platform charges from legacy charges. When set, + /// `charge.refunds` carries the platform refund rows that were recorded + /// or already existed, ordered by `date_added_st` ascending. #[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, } /// Synchronizes a stored platform charge with the latest snapshot from /// Stripe. The charge is identified by `stripe_charge.id`. Fields like @@ -186,7 +186,7 @@ pub struct UpdateChargeRequest { super::super::super::common::v1::StripeCharge, >, } -#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +#[derive(Clone, PartialEq, ::prost::Message)] pub struct UpdateChargeResponse { /// Unset when no platform charge with the given stripe_id exists. #[prost(message, optional, tag = "1")] From 67ce9cff87af89d437a70a7526e9d2b9bb412a8f Mon Sep 17 00:00:00 2001 From: Andrew McKnight Date: Thu, 11 Jun 2026 15:02:34 -0800 Subject: [PATCH 6/9] review(billing-platform): drop refund-aggregate cache from PlatformCharge Per review feedback on getsentry#20611: the cached ``amount_refunded`` / ``refunded`` fields on ``PlatformCharge`` are derivable from the embedded ``refunds`` list -- consumers can sum ``refunds[*].amount_cents`` for the aggregate, and check ``len(refunds) > 0`` (or sum == amount) for "is this refunded." Storing them as separate fields creates a drift risk between the cache and the rows (sentry-protos#303 bugbot). Removing fields 7 (``refunded``) and 8 (``amount_refunded``) from the ``PlatformCharge`` proto. Field numbers reserved so they can't be re-used for an incompatible meaning. The corresponding service-side change (drop the cache write in ``record_charge_refunds``, populate ``refunds`` instead of the aggregate fields) lands in getsentry#20611. The DB columns on ``accounts_platformcharge`` are inherited from ``AbstractCharge`` and remain in place since the legacy ``Charge`` model still uses them; splitting the abstract class to fully drop the columns from ``accounts_platformcharge`` is a separate follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../billing/v1/services/charge/v1/charge.proto | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) 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 ddd4afd4..7d5fc08c 100644 --- a/proto/sentry_protos/billing/v1/services/charge/v1/charge.proto +++ b/proto/sentry_protos/billing/v1/services/charge/v1/charge.proto @@ -14,19 +14,18 @@ message PlatformCharge { bool paid = 4; uint64 amount = 5; optional string failure_code = 6; - bool refunded = 7; - uint64 amount_refunded = 8; + reserved 7, 8; + reserved "refunded", "amount_refunded"; optional string card_last_4 = 9; // Recorded refunds against this charge, ordered by ``date_added_st`` - // ascending. Populated by endpoints that have already paid the cost of - // loading the refund rows (e.g. ``record_charge_refunds``); left empty - // by endpoints that only need the aggregate ``amount_refunded`` cache. + // ascending. Source of truth for refund state on a charge -- consumers + // sum ``refunds[*].amount_cents`` if they need the aggregate, and + // ``len(refunds) > 0`` (or sum == amount) signals "refunded." repeated PlatformRefund refunds = 10; } // 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. +// refund against a ``PlatformCharge``. message PlatformRefund { // Stripe id of the refund (e.g. "re_xxx"). string stripe_id = 1; From 11756084b90248b5d4becff62c4910ac399becbd Mon Sep 17 00:00:00 2001 From: "getsantry[bot]" <66042841+getsantry[bot]@users.noreply.github.com> Date: Thu, 11 Jun 2026 23:20:00 +0000 Subject: [PATCH 7/9] chore: Regenerate Rust bindings --- .../sentry_protos.billing.v1.services.charge.v1.rs | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) 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 0cad5daa..9a190a08 100644 --- a/rust/src/sentry_protos.billing.v1.services.charge.v1.rs +++ b/rust/src/sentry_protos.billing.v1.services.charge.v1.rs @@ -18,22 +18,17 @@ pub struct PlatformCharge { pub amount: u64, #[prost(string, optional, tag = "6")] pub failure_code: ::core::option::Option<::prost::alloc::string::String>, - #[prost(bool, tag = "7")] - pub refunded: bool, - #[prost(uint64, tag = "8")] - pub amount_refunded: u64, #[prost(string, optional, tag = "9")] pub card_last_4: ::core::option::Option<::prost::alloc::string::String>, /// Recorded refunds against this charge, ordered by `date_added_st` - /// ascending. Populated by endpoints that have already paid the cost of - /// loading the refund rows (e.g. `record_charge_refunds`); left empty - /// by endpoints that only need the aggregate `amount_refunded` cache. + /// ascending. Source of truth for refund state on a charge -- consumers + /// sum `refunds\[*\].amount_cents` if they need the aggregate, and + /// `len(refunds) > 0` (or sum == amount) signals "refunded." #[prost(message, repeated, tag = "10")] pub refunds: ::prost::alloc::vec::Vec, } /// 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. +/// refund against a `PlatformCharge`. #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct PlatformRefund { /// Stripe id of the refund (e.g. "re_xxx"). From 7fce3ec51306e75ae8f094f6cf25b9e5dec50770 Mon Sep 17 00:00:00 2001 From: Andrew McKnight Date: Thu, 11 Jun 2026 16:33:01 -0800 Subject: [PATCH 8/9] review(billing-platform): mark refunded/amount_refunded deprecated instead of removing The previous attempt (commit 67ce9cf) removed fields 7 (``refunded``) and 8 (``amount_refunded``) and replaced them with ``reserved 7, 8;`` so the field numbers couldn't be re-used. That tripped buf's ``FIELD_NO_DELETE`` rule, which sentry-protos enables deliberately: PR #168 lifted it once to clean up unused prototype protos, and PR #169 immediately restored it as a permanent guardrail. Restoring the fields with ``[deprecated = true]`` so: - buf is happy (no field deletion). - Consumers get a deprecation marker steering them toward ``refunds[*].amount_cents`` as the source of truth. - No drift risk in practice: the getsentry-side producer (``_charge_to_proto``) is already not populating these fields (getsentry#20611 commit ``96fd005``), and a search confirms no getsentry code reads ``PlatformCharge.refunded`` / ``PlatformCharge.amount_refunded`` -- the proto is internal to the billing platform service. This matches the spirit of Noah's review feedback (don't carry a cache that can drift from the rows) while respecting the repo's field- deletion policy. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../billing/v1/services/charge/v1/charge.proto | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) 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 7d5fc08c..0035d24b 100644 --- a/proto/sentry_protos/billing/v1/services/charge/v1/charge.proto +++ b/proto/sentry_protos/billing/v1/services/charge/v1/charge.proto @@ -14,8 +14,17 @@ message PlatformCharge { bool paid = 4; uint64 amount = 5; optional string failure_code = 6; - reserved 7, 8; - reserved "refunded", "amount_refunded"; + // DEPRECATED: Source of truth for refund state is ``refunds`` below. + // Consumers should check ``len(refunds) > 0`` (or + // ``sum(refunds[*].amount_cents) == amount``) instead. Left in the proto + // because ``sentry-protos`` policy disallows field deletion. Not + // populated by current producers. + bool refunded = 7 [deprecated = true]; + // DEPRECATED: Source of truth for refund amount is the sum of + // ``refunds[*].amount_cents`` below. Left in the proto because + // ``sentry-protos`` policy disallows field deletion. Not populated by + // current producers. + uint64 amount_refunded = 8 [deprecated = true]; optional string card_last_4 = 9; // Recorded refunds against this charge, ordered by ``date_added_st`` // ascending. Source of truth for refund state on a charge -- consumers From 3167124d3a834305093563180f092f471fc8dc57 Mon Sep 17 00:00:00 2001 From: "getsantry[bot]" <66042841+getsantry[bot]@users.noreply.github.com> Date: Fri, 12 Jun 2026 00:34:16 +0000 Subject: [PATCH 9/9] chore: Regenerate Rust bindings --- ...sentry_protos.billing.v1.services.charge.v1.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) 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 9a190a08..36e20b94 100644 --- a/rust/src/sentry_protos.billing.v1.services.charge.v1.rs +++ b/rust/src/sentry_protos.billing.v1.services.charge.v1.rs @@ -18,6 +18,21 @@ pub struct PlatformCharge { pub amount: u64, #[prost(string, optional, tag = "6")] pub failure_code: ::core::option::Option<::prost::alloc::string::String>, + /// DEPRECATED: Source of truth for refund state is `refunds` below. + /// Consumers should check `len(refunds) > 0` (or + /// `sum(refunds\[*\].amount_cents) == amount`) instead. Left in the proto + /// because `sentry-protos` policy disallows field deletion. Not + /// populated by current producers. + #[deprecated] + #[prost(bool, tag = "7")] + pub refunded: bool, + /// DEPRECATED: Source of truth for refund amount is the sum of + /// `refunds\[*\].amount_cents` below. Left in the proto because + /// `sentry-protos` policy disallows field deletion. Not populated by + /// current producers. + #[deprecated] + #[prost(uint64, tag = "8")] + pub amount_refunded: u64, #[prost(string, optional, tag = "9")] pub card_last_4: ::core::option::Option<::prost::alloc::string::String>, /// Recorded refunds against this charge, ordered by `date_added_st`