From a1b2132fa16a9aae6ce71d29ae7c5fb21cecfd37 Mon Sep 17 00:00:00 2001 From: Volo Kluev Date: Fri, 9 Oct 2026 14:43:36 -0700 Subject: [PATCH 1/2] feat(billing): Add one-off invoice protos Add InvoiceKind, Invoice.kind, Invoice.idempotency_key and InvoiceLineItem.external_id so the billing platform can bill an organization outside of a contract, and a OneOffInvoiceUpdated event so the requesting service learns when such an invoice is paid, closed unpaid or refunded. --- Cargo.lock | 2 +- .../v1/services/contract/v1/invoice.proto | 18 +++++ .../events/v1/billing_platform_event.proto | 2 + .../events/v1/one_off_invoice_updated.proto | 24 +++++++ ..._protos.billing.v1.services.contract.v1.rs | 42 ++++++++++++ ...ry_protos.billing.v1.services.events.v1.rs | 68 ++++++++++++++++++- 6 files changed, 154 insertions(+), 2 deletions(-) create mode 100644 proto/sentry_protos/billing/v1/services/events/v1/one_off_invoice_updated.proto diff --git a/Cargo.lock b/Cargo.lock index 56fa1bdd..20d57b06 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -717,7 +717,7 @@ checksum = "955d28af4278de8121b7ebeb796b6a45735dc01436d898801014aced2773a3d6" [[package]] name = "sentry_protos" -version = "0.81.1" +version = "0.82.0" dependencies = [ "prost", "prost-types", diff --git a/proto/sentry_protos/billing/v1/services/contract/v1/invoice.proto b/proto/sentry_protos/billing/v1/services/contract/v1/invoice.proto index 4896bbba..1e48dc59 100644 --- a/proto/sentry_protos/billing/v1/services/contract/v1/invoice.proto +++ b/proto/sentry_protos/billing/v1/services/contract/v1/invoice.proto @@ -6,6 +6,15 @@ import "google/protobuf/timestamp.proto"; import "sentry_protos/billing/v1/common/v1/address.proto"; import "sentry_protos/billing/v1/common/v1/external_billing_provider.proto"; +enum InvoiceKind { + // Invoices written before kinds existed; these are all contract invoices. + INVOICE_KIND_UNSPECIFIED = 0; + // Created or rolled over alongside a contract. + INVOICE_KIND_CONTRACT = 1; + // Billed to an organization outside of any contract, charged immediately. + INVOICE_KIND_ONE_OFF = 2; +} + // This is not the same as LineItemDetails, it includes // items not related to the package such as tax. Each line item may carry an // optional type that classifies it (e.g. "tax") so consumers can treat it @@ -17,6 +26,9 @@ message InvoiceLineItem { // Optional classifier for the line item, e.g. "tax". Unset for ordinary // priced line items. optional string type = 3; + // Caller-defined identifier for what this line bills, set on one-off + // invoice lines. Opaque to the platform. + optional string external_id = 4; } message Invoice { @@ -52,4 +64,10 @@ message Invoice { // (e.g. pre-migration shadow rollovers and the $0 migration stub). Default // false means the invoice is visible on receipts. bool hide_from_customer_receipts = 15; + + InvoiceKind kind = 16; + + // Caller-supplied key that makes one-off invoice creation safe to retry. + // Unique per organization; unset on contract invoices. + optional string idempotency_key = 17; } diff --git a/proto/sentry_protos/billing/v1/services/events/v1/billing_platform_event.proto b/proto/sentry_protos/billing/v1/services/events/v1/billing_platform_event.proto index 77d39408..5146bc7b 100644 --- a/proto/sentry_protos/billing/v1/services/events/v1/billing_platform_event.proto +++ b/proto/sentry_protos/billing/v1/services/events/v1/billing_platform_event.proto @@ -5,6 +5,7 @@ package sentry_protos.billing.v1.services.events.v1; import "google/protobuf/timestamp.proto"; import "sentry_protos/billing/v1/services/events/v1/billing_alert_evaluation_requested.proto"; import "sentry_protos/billing/v1/services/events/v1/contract_rolled_over.proto"; +import "sentry_protos/billing/v1/services/events/v1/one_off_invoice_updated.proto"; import "sentry_protos/billing/v1/services/events/v1/organization_over_usage.proto"; import "sentry_protos/billing/v1/services/events/v1/organization_package_changed.proto"; import "sentry_protos/billing/v1/services/events/v1/organization_payg_changed.proto"; @@ -25,6 +26,7 @@ message EventPayload { sentry_protos.billing.v1.services.events.v1.ContractRolledOver contract_rolled_over = 5; sentry_protos.billing.v1.services.events.v1.OrganizationPackageChanged organization_package_changed = 6; sentry_protos.billing.v1.services.events.v1.BillingAlertEvaluationRequested billing_alert_evaluation_requested = 7; + sentry_protos.billing.v1.services.events.v1.OneOffInvoiceUpdated one_off_invoice_updated = 8; } } diff --git a/proto/sentry_protos/billing/v1/services/events/v1/one_off_invoice_updated.proto b/proto/sentry_protos/billing/v1/services/events/v1/one_off_invoice_updated.proto new file mode 100644 index 00000000..1b2b6abd --- /dev/null +++ b/proto/sentry_protos/billing/v1/services/events/v1/one_off_invoice_updated.proto @@ -0,0 +1,24 @@ +syntax = "proto3"; + +package sentry_protos.billing.v1.services.events.v1; + +// Published when a one-off invoice changes state, so the service that +// requested it can grant, withhold or claw back what was purchased. +message OneOffInvoiceUpdated { + enum Outcome { + OUTCOME_UNSPECIFIED = 0; + OUTCOME_PAID = 1; + // Retries ran out or the invoice was closed. It can still be paid later, + // which publishes OUTCOME_PAID. + OUTCOME_CLOSED_UNPAID = 2; + OUTCOME_REFUNDED = 3; + } + + uint64 invoice_id = 1; + string invoice_guid = 2; + string idempotency_key = 3; + Outcome outcome = 4; + // PAID: the invoice's amount_billed. REFUNDED: the amount of this refund, + // for the invoice as a whole rather than per line. + uint64 amount_cents = 5; +} diff --git a/rust/src/sentry_protos.billing.v1.services.contract.v1.rs b/rust/src/sentry_protos.billing.v1.services.contract.v1.rs index 3d3d61cc..bb1101fa 100644 --- a/rust/src/sentry_protos.billing.v1.services.contract.v1.rs +++ b/rust/src/sentry_protos.billing.v1.services.contract.v1.rs @@ -547,6 +547,10 @@ pub struct InvoiceLineItem { /// priced line items. #[prost(string, optional, tag = "3")] pub r#type: ::core::option::Option<::prost::alloc::string::String>, + /// Caller-defined identifier for what this line bills, set on one-off + /// invoice lines. Opaque to the platform. + #[prost(string, optional, tag = "4")] + pub external_id: ::core::option::Option<::prost::alloc::string::String>, } #[derive(Clone, PartialEq, ::prost::Message)] pub struct Invoice { @@ -595,6 +599,44 @@ pub struct Invoice { /// false means the invoice is visible on receipts. #[prost(bool, tag = "15")] pub hide_from_customer_receipts: bool, + #[prost(enumeration = "InvoiceKind", tag = "16")] + pub kind: i32, + /// Caller-supplied key that makes one-off invoice creation safe to retry. + /// Unique per organization; unset on contract invoices. + #[prost(string, optional, tag = "17")] + pub idempotency_key: ::core::option::Option<::prost::alloc::string::String>, +} +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] +#[repr(i32)] +pub enum InvoiceKind { + /// Invoices written before kinds existed; these are all contract invoices. + Unspecified = 0, + /// Created or rolled over alongside a contract. + Contract = 1, + /// Billed to an organization outside of any contract, charged immediately. + OneOff = 2, +} +impl InvoiceKind { + /// String value of the enum field names used in the ProtoBuf definition. + /// + /// The values are not transformed in any way and thus are considered stable + /// (if the ProtoBuf definition does not change) and safe for programmatic use. + pub fn as_str_name(&self) -> &'static str { + match self { + Self::Unspecified => "INVOICE_KIND_UNSPECIFIED", + Self::Contract => "INVOICE_KIND_CONTRACT", + Self::OneOff => "INVOICE_KIND_ONE_OFF", + } + } + /// Creates an enum from field names used in the ProtoBuf definition. + pub fn from_str_name(value: &str) -> ::core::option::Option { + match value { + "INVOICE_KIND_UNSPECIFIED" => Some(Self::Unspecified), + "INVOICE_KIND_CONTRACT" => Some(Self::Contract), + "INVOICE_KIND_ONE_OFF" => Some(Self::OneOff), + _ => None, + } + } } #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct OptionValue { diff --git a/rust/src/sentry_protos.billing.v1.services.events.v1.rs b/rust/src/sentry_protos.billing.v1.services.events.v1.rs index 01cea830..7c78fdd4 100644 --- a/rust/src/sentry_protos.billing.v1.services.events.v1.rs +++ b/rust/src/sentry_protos.billing.v1.services.events.v1.rs @@ -6,6 +6,70 @@ pub struct BillingAlertEvaluationRequested {} /// Future: uint64 new_contract_id = 2; #[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] pub struct ContractRolledOver {} +/// Published when a one-off invoice changes state, so the service that +/// requested it can grant, withhold or claw back what was purchased. +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct OneOffInvoiceUpdated { + #[prost(uint64, tag = "1")] + pub invoice_id: u64, + #[prost(string, tag = "2")] + pub invoice_guid: ::prost::alloc::string::String, + #[prost(string, tag = "3")] + pub idempotency_key: ::prost::alloc::string::String, + #[prost(enumeration = "one_off_invoice_updated::Outcome", tag = "4")] + pub outcome: i32, + /// PAID: the invoice's amount_billed. REFUNDED: the amount of this refund, + /// for the invoice as a whole rather than per line. + #[prost(uint64, tag = "5")] + pub amount_cents: u64, +} +/// Nested message and enum types in `OneOffInvoiceUpdated`. +pub mod one_off_invoice_updated { + #[derive( + Clone, + Copy, + Debug, + PartialEq, + Eq, + Hash, + PartialOrd, + Ord, + ::prost::Enumeration + )] + #[repr(i32)] + pub enum Outcome { + Unspecified = 0, + Paid = 1, + /// Retries ran out or the invoice was closed. It can still be paid later, + /// which publishes OUTCOME_PAID. + ClosedUnpaid = 2, + Refunded = 3, + } + impl Outcome { + /// String value of the enum field names used in the ProtoBuf definition. + /// + /// The values are not transformed in any way and thus are considered stable + /// (if the ProtoBuf definition does not change) and safe for programmatic use. + pub fn as_str_name(&self) -> &'static str { + match self { + Self::Unspecified => "OUTCOME_UNSPECIFIED", + Self::Paid => "OUTCOME_PAID", + Self::ClosedUnpaid => "OUTCOME_CLOSED_UNPAID", + Self::Refunded => "OUTCOME_REFUNDED", + } + } + /// Creates an enum from field names used in the ProtoBuf definition. + pub fn from_str_name(value: &str) -> ::core::option::Option { + match value { + "OUTCOME_UNSPECIFIED" => Some(Self::Unspecified), + "OUTCOME_PAID" => Some(Self::Paid), + "OUTCOME_CLOSED_UNPAID" => Some(Self::ClosedUnpaid), + "OUTCOME_REFUNDED" => Some(Self::Refunded), + _ => None, + } + } + } +} #[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] pub struct OrganizationOverUsage {} /// Published when an organization's contract moves to a different package, e.g. a @@ -33,7 +97,7 @@ pub struct EventMeta { } #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct EventPayload { - #[prost(oneof = "event_payload::Payload", tags = "1, 2, 3, 4, 5, 6, 7")] + #[prost(oneof = "event_payload::Payload", tags = "1, 2, 3, 4, 5, 6, 7, 8")] pub payload: ::core::option::Option, } /// Nested message and enum types in `EventPayload`. @@ -54,6 +118,8 @@ pub mod event_payload { OrganizationPackageChanged(super::OrganizationPackageChanged), #[prost(message, tag = "7")] BillingAlertEvaluationRequested(super::BillingAlertEvaluationRequested), + #[prost(message, tag = "8")] + OneOffInvoiceUpdated(super::OneOffInvoiceUpdated), } } #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] From 0c7056019cd25e31fe19ec5d6fbbffca519bfaaf Mon Sep 17 00:00:00 2001 From: Volo Kluev Date: Fri, 9 Oct 2026 15:51:43 -0700 Subject: [PATCH 2/2] feat(billing): Add client_id to one-off invoices Identify the service that requested a one-off invoice on the Invoice and on OneOffInvoiceUpdated, so a handler can recognize its own invoices and map idempotency_key back to its records. --- .../billing/v1/services/contract/v1/invoice.proto | 7 ++++++- .../v1/services/events/v1/one_off_invoice_updated.proto | 3 +++ rust/src/sentry_protos.billing.v1.services.contract.v1.rs | 7 ++++++- rust/src/sentry_protos.billing.v1.services.events.v1.rs | 4 ++++ 4 files changed, 19 insertions(+), 2 deletions(-) diff --git a/proto/sentry_protos/billing/v1/services/contract/v1/invoice.proto b/proto/sentry_protos/billing/v1/services/contract/v1/invoice.proto index 1e48dc59..92a77602 100644 --- a/proto/sentry_protos/billing/v1/services/contract/v1/invoice.proto +++ b/proto/sentry_protos/billing/v1/services/contract/v1/invoice.proto @@ -68,6 +68,11 @@ message Invoice { InvoiceKind kind = 16; // Caller-supplied key that makes one-off invoice creation safe to retry. - // Unique per organization; unset on contract invoices. + // Unique per organization and client_id; unset on contract invoices. optional string idempotency_key = 17; + + // Identifies the service that requested a one-off invoice, so it can + // recognize its own invoices and map idempotency_key back to its own + // records. Unset on contract invoices. + optional string client_id = 18; } diff --git a/proto/sentry_protos/billing/v1/services/events/v1/one_off_invoice_updated.proto b/proto/sentry_protos/billing/v1/services/events/v1/one_off_invoice_updated.proto index 1b2b6abd..539e50b1 100644 --- a/proto/sentry_protos/billing/v1/services/events/v1/one_off_invoice_updated.proto +++ b/proto/sentry_protos/billing/v1/services/events/v1/one_off_invoice_updated.proto @@ -21,4 +21,7 @@ message OneOffInvoiceUpdated { // PAID: the invoice's amount_billed. REFUNDED: the amount of this refund, // for the invoice as a whole rather than per line. uint64 amount_cents = 5; + // The service that requested the invoice. A handler acts only on events + // carrying its own client_id; idempotency_key is unique within it. + string client_id = 6; } diff --git a/rust/src/sentry_protos.billing.v1.services.contract.v1.rs b/rust/src/sentry_protos.billing.v1.services.contract.v1.rs index bb1101fa..421b536f 100644 --- a/rust/src/sentry_protos.billing.v1.services.contract.v1.rs +++ b/rust/src/sentry_protos.billing.v1.services.contract.v1.rs @@ -602,9 +602,14 @@ pub struct Invoice { #[prost(enumeration = "InvoiceKind", tag = "16")] pub kind: i32, /// Caller-supplied key that makes one-off invoice creation safe to retry. - /// Unique per organization; unset on contract invoices. + /// Unique per organization and client_id; unset on contract invoices. #[prost(string, optional, tag = "17")] pub idempotency_key: ::core::option::Option<::prost::alloc::string::String>, + /// Identifies the service that requested a one-off invoice, so it can + /// recognize its own invoices and map idempotency_key back to its own + /// records. Unset on contract invoices. + #[prost(string, optional, tag = "18")] + pub client_id: ::core::option::Option<::prost::alloc::string::String>, } #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] #[repr(i32)] diff --git a/rust/src/sentry_protos.billing.v1.services.events.v1.rs b/rust/src/sentry_protos.billing.v1.services.events.v1.rs index 7c78fdd4..ae6d34d7 100644 --- a/rust/src/sentry_protos.billing.v1.services.events.v1.rs +++ b/rust/src/sentry_protos.billing.v1.services.events.v1.rs @@ -22,6 +22,10 @@ pub struct OneOffInvoiceUpdated { /// for the invoice as a whole rather than per line. #[prost(uint64, tag = "5")] pub amount_cents: u64, + /// The service that requested the invoice. A handler acts only on events + /// carrying its own client_id; idempotency_key is unique within it. + #[prost(string, tag = "6")] + pub client_id: ::prost::alloc::string::String, } /// Nested message and enum types in `OneOffInvoiceUpdated`. pub mod one_off_invoice_updated {