From 6ed6ed3f7b4708823c5090e74acb2a418b09a7a8 Mon Sep 17 00:00:00 2001 From: Volo Kluev Date: Fri, 23 Oct 2026 16:22:48 -0700 Subject: [PATCH 1/6] feat(billing): Add deposit ledger protos to the contract service Add a ledger package under services/contract for an organization's prepaid credit accounts. It defines LedgerEntry and endpoints to record deposits, withdrawals, and adjustments, read the balance, and list entries. A deposit is tied to an off-cycle invoice and counts toward the balance only once that invoice is paid. A withdrawal never makes the balance negative; callers choose between a partial withdrawal and none. --- .../v1/endpoint_get_ledger_balance.proto | 16 ++++++++ .../v1/endpoint_list_ledger_entries.proto | 22 +++++++++++ .../endpoint_record_ledger_adjustment.proto | 29 +++++++++++++++ .../v1/endpoint_record_ledger_deposit.proto | 25 +++++++++++++ .../endpoint_record_ledger_withdrawal.proto | 37 +++++++++++++++++++ .../contract/ledger/v1/ledger_entry.proto | 37 +++++++++++++++++++ 6 files changed, 166 insertions(+) create mode 100644 proto/sentry_protos/billing/v1/services/contract/ledger/v1/endpoint_get_ledger_balance.proto create mode 100644 proto/sentry_protos/billing/v1/services/contract/ledger/v1/endpoint_list_ledger_entries.proto create mode 100644 proto/sentry_protos/billing/v1/services/contract/ledger/v1/endpoint_record_ledger_adjustment.proto create mode 100644 proto/sentry_protos/billing/v1/services/contract/ledger/v1/endpoint_record_ledger_deposit.proto create mode 100644 proto/sentry_protos/billing/v1/services/contract/ledger/v1/endpoint_record_ledger_withdrawal.proto create mode 100644 proto/sentry_protos/billing/v1/services/contract/ledger/v1/ledger_entry.proto diff --git a/proto/sentry_protos/billing/v1/services/contract/ledger/v1/endpoint_get_ledger_balance.proto b/proto/sentry_protos/billing/v1/services/contract/ledger/v1/endpoint_get_ledger_balance.proto new file mode 100644 index 00000000..1c2420d3 --- /dev/null +++ b/proto/sentry_protos/billing/v1/services/contract/ledger/v1/endpoint_get_ledger_balance.proto @@ -0,0 +1,16 @@ +syntax = "proto3"; + +package sentry_protos.billing.v1.services.contract.ledger.v1; + +message GetLedgerBalanceRequest { + uint64 organization_id = 1; + string account_id = 2; +} + +message GetLedgerBalanceResponse { + // Paid deposits minus withdrawals, plus adjustments. Zero for an account + // with no entries. + int64 balance_cents = 1; + // Deposits whose invoices are not paid yet. Not part of balance_cents. + int64 pending_deposit_cents = 2; +} diff --git a/proto/sentry_protos/billing/v1/services/contract/ledger/v1/endpoint_list_ledger_entries.proto b/proto/sentry_protos/billing/v1/services/contract/ledger/v1/endpoint_list_ledger_entries.proto new file mode 100644 index 00000000..a6cb9a66 --- /dev/null +++ b/proto/sentry_protos/billing/v1/services/contract/ledger/v1/endpoint_list_ledger_entries.proto @@ -0,0 +1,22 @@ +syntax = "proto3"; + +package sentry_protos.billing.v1.services.contract.ledger.v1; + +import "sentry_protos/billing/v1/services/contract/ledger/v1/ledger_entry.proto"; + +// Lists an account's entries, newest first. +message ListLedgerEntriesRequest { + uint64 organization_id = 1; + string account_id = 2; + // Return only entries older than this one. Pass next_cursor from the + // previous page. + optional uint64 before_entry_id = 3; + // Defaults to 100 when zero; capped at 1000. + uint32 limit = 4; +} + +message ListLedgerEntriesResponse { + repeated LedgerEntry entries = 1; + // Unset on the last page. + optional uint64 next_cursor = 2; +} diff --git a/proto/sentry_protos/billing/v1/services/contract/ledger/v1/endpoint_record_ledger_adjustment.proto b/proto/sentry_protos/billing/v1/services/contract/ledger/v1/endpoint_record_ledger_adjustment.proto new file mode 100644 index 00000000..7f5770a2 --- /dev/null +++ b/proto/sentry_protos/billing/v1/services/contract/ledger/v1/endpoint_record_ledger_adjustment.proto @@ -0,0 +1,29 @@ +syntax = "proto3"; + +package sentry_protos.billing.v1.services.contract.ledger.v1; + +import "sentry_protos/billing/v1/services/contract/ledger/v1/ledger_entry.proto"; + +// Records a manual correction. A refund is a negative adjustment: the refunded +// invoice stays paid, so its deposit still counts and the adjustment cancels +// it. Unlike a withdrawal, an adjustment can make the balance negative. +message RecordLedgerAdjustmentRequest { + uint64 organization_id = 1; + string account_id = 2; + // Must not be zero. + int64 amount_cents = 3; + // Unique per organization and account_id. A repeated key writes nothing + // and returns the existing entry with created=false. + string idempotency_key = 4; + // Required, so that the ledger shows why the balance changed. + string description = 5; + // The invoice the adjustment refers to, e.g. the refunded one. + optional uint64 invoice_id = 6; +} + +message RecordLedgerAdjustmentResponse { + LedgerEntry entry = 1; + bool created = 2; + // The balance after the adjustment. + int64 balance_cents = 3; +} diff --git a/proto/sentry_protos/billing/v1/services/contract/ledger/v1/endpoint_record_ledger_deposit.proto b/proto/sentry_protos/billing/v1/services/contract/ledger/v1/endpoint_record_ledger_deposit.proto new file mode 100644 index 00000000..052d7bd2 --- /dev/null +++ b/proto/sentry_protos/billing/v1/services/contract/ledger/v1/endpoint_record_ledger_deposit.proto @@ -0,0 +1,25 @@ +syntax = "proto3"; + +package sentry_protos.billing.v1.services.contract.ledger.v1; + +import "sentry_protos/billing/v1/services/contract/ledger/v1/ledger_entry.proto"; + +// Records credit bought through an off-cycle invoice. The balance excludes +// the deposit until the invoice is paid, so record it as soon as the invoice +// exists. An invoice holds at most one deposit: a repeated request writes +// nothing and returns the existing entry with created=false. +message RecordLedgerDepositRequest { + uint64 organization_id = 1; + string account_id = 2; + // Must belong to organization_id. + uint64 invoice_id = 3; + // The credit bought, excluding tax. Must be positive and at most the + // invoice amount. + uint64 amount_cents = 4; + optional string description = 5; +} + +message RecordLedgerDepositResponse { + LedgerEntry entry = 1; + bool created = 2; +} diff --git a/proto/sentry_protos/billing/v1/services/contract/ledger/v1/endpoint_record_ledger_withdrawal.proto b/proto/sentry_protos/billing/v1/services/contract/ledger/v1/endpoint_record_ledger_withdrawal.proto new file mode 100644 index 00000000..901e0ce6 --- /dev/null +++ b/proto/sentry_protos/billing/v1/services/contract/ledger/v1/endpoint_record_ledger_withdrawal.proto @@ -0,0 +1,37 @@ +syntax = "proto3"; + +package sentry_protos.billing.v1.services.contract.ledger.v1; + +import "sentry_protos/billing/v1/services/contract/ledger/v1/ledger_entry.proto"; + +// Takes credit out of an account. A withdrawal never makes the balance +// negative. The balance check and the write are atomic, so concurrent +// withdrawals can not spend the same credit. +message RecordLedgerWithdrawalRequest { + uint64 organization_id = 1; + string account_id = 2; + uint64 amount_cents = 3; + // Unique per organization and account_id. A repeated key writes nothing + // and returns the existing entry with created=false. + string idempotency_key = 4; + // The contract whose usage the withdrawal pays for. Defaults to the + // organization's current contract. + optional uint64 contract_id = 5; + // When the balance is less than amount_cents: if true, withdraw the whole + // balance and report the remainder in shortfall_cents; if false, write + // nothing and set shortfall_cents to amount_cents. + bool allow_partial = 6; + optional string description = 7; +} + +message RecordLedgerWithdrawalResponse { + // Unset when nothing was written. + optional LedgerEntry entry = 1; + uint64 withdrawn_cents = 2; + // The part of amount_cents that the balance did not cover. The caller + // must collect it some other way. + uint64 shortfall_cents = 3; + // The balance after the withdrawal. + int64 balance_cents = 4; + bool created = 5; +} diff --git a/proto/sentry_protos/billing/v1/services/contract/ledger/v1/ledger_entry.proto b/proto/sentry_protos/billing/v1/services/contract/ledger/v1/ledger_entry.proto new file mode 100644 index 00000000..682f6215 --- /dev/null +++ b/proto/sentry_protos/billing/v1/services/contract/ledger/v1/ledger_entry.proto @@ -0,0 +1,37 @@ +syntax = "proto3"; + +package sentry_protos.billing.v1.services.contract.ledger.v1; + +import "google/protobuf/timestamp.proto"; + +enum LedgerEntryType { + LEDGER_ENTRY_TYPE_UNSPECIFIED = 0; + // Credit bought through an invoice. It adds to the balance only after the + // invoice is paid. + LEDGER_ENTRY_TYPE_DEPOSIT = 1; + // Credit used, e.g. the credit-only usage tallied at contract rollover. + LEDGER_ENTRY_TYPE_WITHDRAWAL = 2; + // A manual correction, such as a refund. It can be positive or negative. + LEDGER_ENTRY_TYPE_ADJUSTMENT = 3; +} + +// One row of an account's transaction history. Entries are append-only. +message LedgerEntry { + uint64 id = 1; + uint64 organization_id = 2; + // Identifies one balance among the organization's accounts, e.g. "seer". + string account_id = 3; + // The organization's contract when the entry was written, if it had one. + optional uint64 contract_id = 4; + // Signed: deposits are positive, withdrawals are negative or zero. + int64 amount_cents = 5; + LedgerEntryType entry_type = 6; + // The invoice that pays for a deposit, or that an adjustment refers to. + optional uint64 invoice_id = 7; + google.protobuf.Timestamp date_added = 8; + optional string idempotency_key = 9; + optional string description = 10; + // False only for a deposit whose invoice is not paid yet. Such an entry + // does not count toward the balance. + bool counts_toward_balance = 11; +} From 8edd33035fa700684de1fbe25e95c0dff62ec6d7 Mon Sep 17 00:00:00 2001 From: "getsantry[bot]" <66042841+getsantry[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:23:58 +0000 Subject: [PATCH 2/6] chore: Regenerate Rust bindings --- Cargo.lock | 2 +- rust/src/_include.rs | 5 + ....billing.v1.services.contract.ledger.v1.rs | 213 ++++++++++++++++++ 3 files changed, 219 insertions(+), 1 deletion(-) create mode 100644 rust/src/sentry_protos.billing.v1.services.contract.ledger.v1.rs diff --git a/Cargo.lock b/Cargo.lock index 09d2f89c..0af9158c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -717,7 +717,7 @@ checksum = "955d28af4278de8121b7ebeb796b6a45735dc01436d898801014aced2773a3d6" [[package]] name = "sentry_protos" -version = "0.90.0" +version = "0.91.0" dependencies = [ "prost", "prost-types", diff --git a/rust/src/_include.rs b/rust/src/_include.rs index bfed6562..f773a526 100644 --- a/rust/src/_include.rs +++ b/rust/src/_include.rs @@ -35,6 +35,11 @@ pub mod sentry_protos { } } pub mod contract { + pub mod ledger { + pub mod v1 { + include!("sentry_protos.billing.v1.services.contract.ledger.v1.rs"); + } + } pub mod v1 { include!("sentry_protos.billing.v1.services.contract.v1.rs"); } diff --git a/rust/src/sentry_protos.billing.v1.services.contract.ledger.v1.rs b/rust/src/sentry_protos.billing.v1.services.contract.ledger.v1.rs new file mode 100644 index 00000000..484015fa --- /dev/null +++ b/rust/src/sentry_protos.billing.v1.services.contract.ledger.v1.rs @@ -0,0 +1,213 @@ +// This file is @generated by prost-build. +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct GetLedgerBalanceRequest { + #[prost(uint64, tag = "1")] + pub organization_id: u64, + #[prost(string, tag = "2")] + pub account_id: ::prost::alloc::string::String, +} +#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] +pub struct GetLedgerBalanceResponse { + /// Paid deposits minus withdrawals, plus adjustments. Zero for an account + /// with no entries. + #[prost(int64, tag = "1")] + pub balance_cents: i64, + /// Deposits whose invoices are not paid yet. Not part of balance_cents. + #[prost(int64, tag = "2")] + pub pending_deposit_cents: i64, +} +/// One row of an account's transaction history. Entries are append-only. +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct LedgerEntry { + #[prost(uint64, tag = "1")] + pub id: u64, + #[prost(uint64, tag = "2")] + pub organization_id: u64, + /// Identifies one balance among the organization's accounts, e.g. "seer". + #[prost(string, tag = "3")] + pub account_id: ::prost::alloc::string::String, + /// The organization's contract when the entry was written, if it had one. + #[prost(uint64, optional, tag = "4")] + pub contract_id: ::core::option::Option, + /// Signed: deposits are positive, withdrawals are negative or zero. + #[prost(int64, tag = "5")] + pub amount_cents: i64, + #[prost(enumeration = "LedgerEntryType", tag = "6")] + pub entry_type: i32, + /// The invoice that pays for a deposit, or that an adjustment refers to. + #[prost(uint64, optional, tag = "7")] + pub invoice_id: ::core::option::Option, + #[prost(message, optional, tag = "8")] + pub date_added: ::core::option::Option<::prost_types::Timestamp>, + #[prost(string, optional, tag = "9")] + pub idempotency_key: ::core::option::Option<::prost::alloc::string::String>, + #[prost(string, optional, tag = "10")] + pub description: ::core::option::Option<::prost::alloc::string::String>, + /// False only for a deposit whose invoice is not paid yet. Such an entry + /// does not count toward the balance. + #[prost(bool, tag = "11")] + pub counts_toward_balance: bool, +} +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] +#[repr(i32)] +pub enum LedgerEntryType { + Unspecified = 0, + /// Credit bought through an invoice. It adds to the balance only after the + /// invoice is paid. + Deposit = 1, + /// Credit used, e.g. the credit-only usage tallied at contract rollover. + Withdrawal = 2, + /// A manual correction, such as a refund. It can be positive or negative. + Adjustment = 3, +} +impl LedgerEntryType { + /// 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 => "LEDGER_ENTRY_TYPE_UNSPECIFIED", + Self::Deposit => "LEDGER_ENTRY_TYPE_DEPOSIT", + Self::Withdrawal => "LEDGER_ENTRY_TYPE_WITHDRAWAL", + Self::Adjustment => "LEDGER_ENTRY_TYPE_ADJUSTMENT", + } + } + /// Creates an enum from field names used in the ProtoBuf definition. + pub fn from_str_name(value: &str) -> ::core::option::Option { + match value { + "LEDGER_ENTRY_TYPE_UNSPECIFIED" => Some(Self::Unspecified), + "LEDGER_ENTRY_TYPE_DEPOSIT" => Some(Self::Deposit), + "LEDGER_ENTRY_TYPE_WITHDRAWAL" => Some(Self::Withdrawal), + "LEDGER_ENTRY_TYPE_ADJUSTMENT" => Some(Self::Adjustment), + _ => None, + } + } +} +/// Lists an account's entries, newest first. +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct ListLedgerEntriesRequest { + #[prost(uint64, tag = "1")] + pub organization_id: u64, + #[prost(string, tag = "2")] + pub account_id: ::prost::alloc::string::String, + /// Return only entries older than this one. Pass next_cursor from the + /// previous page. + #[prost(uint64, optional, tag = "3")] + pub before_entry_id: ::core::option::Option, + /// Defaults to 100 when zero; capped at 1000. + #[prost(uint32, tag = "4")] + pub limit: u32, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct ListLedgerEntriesResponse { + #[prost(message, repeated, tag = "1")] + pub entries: ::prost::alloc::vec::Vec, + /// Unset on the last page. + #[prost(uint64, optional, tag = "2")] + pub next_cursor: ::core::option::Option, +} +/// Records a manual correction. A refund is a negative adjustment: the refunded +/// invoice stays paid, so its deposit still counts and the adjustment cancels +/// it. Unlike a withdrawal, an adjustment can make the balance negative. +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct RecordLedgerAdjustmentRequest { + #[prost(uint64, tag = "1")] + pub organization_id: u64, + #[prost(string, tag = "2")] + pub account_id: ::prost::alloc::string::String, + /// Must not be zero. + #[prost(int64, tag = "3")] + pub amount_cents: i64, + /// Unique per organization and account_id. A repeated key writes nothing + /// and returns the existing entry with created=false. + #[prost(string, tag = "4")] + pub idempotency_key: ::prost::alloc::string::String, + /// Required, so that the ledger shows why the balance changed. + #[prost(string, tag = "5")] + pub description: ::prost::alloc::string::String, + /// The invoice the adjustment refers to, e.g. the refunded one. + #[prost(uint64, optional, tag = "6")] + pub invoice_id: ::core::option::Option, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct RecordLedgerAdjustmentResponse { + #[prost(message, optional, tag = "1")] + pub entry: ::core::option::Option, + #[prost(bool, tag = "2")] + pub created: bool, + /// The balance after the adjustment. + #[prost(int64, tag = "3")] + pub balance_cents: i64, +} +/// Records credit bought through an off-cycle invoice. The balance excludes +/// the deposit until the invoice is paid, so record it as soon as the invoice +/// exists. An invoice holds at most one deposit: a repeated request writes +/// nothing and returns the existing entry with created=false. +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct RecordLedgerDepositRequest { + #[prost(uint64, tag = "1")] + pub organization_id: u64, + #[prost(string, tag = "2")] + pub account_id: ::prost::alloc::string::String, + /// Must belong to organization_id. + #[prost(uint64, tag = "3")] + pub invoice_id: u64, + /// The credit bought, excluding tax. Must be positive and at most the + /// invoice amount. + #[prost(uint64, tag = "4")] + pub amount_cents: u64, + #[prost(string, optional, tag = "5")] + pub description: ::core::option::Option<::prost::alloc::string::String>, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct RecordLedgerDepositResponse { + #[prost(message, optional, tag = "1")] + pub entry: ::core::option::Option, + #[prost(bool, tag = "2")] + pub created: bool, +} +/// Takes credit out of an account. A withdrawal never makes the balance +/// negative. The balance check and the write are atomic, so concurrent +/// withdrawals can not spend the same credit. +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct RecordLedgerWithdrawalRequest { + #[prost(uint64, tag = "1")] + pub organization_id: u64, + #[prost(string, tag = "2")] + pub account_id: ::prost::alloc::string::String, + #[prost(uint64, tag = "3")] + pub amount_cents: u64, + /// Unique per organization and account_id. A repeated key writes nothing + /// and returns the existing entry with created=false. + #[prost(string, tag = "4")] + pub idempotency_key: ::prost::alloc::string::String, + /// The contract whose usage the withdrawal pays for. Defaults to the + /// organization's current contract. + #[prost(uint64, optional, tag = "5")] + pub contract_id: ::core::option::Option, + /// When the balance is less than amount_cents: if true, withdraw the whole + /// balance and report the remainder in shortfall_cents; if false, write + /// nothing and set shortfall_cents to amount_cents. + #[prost(bool, tag = "6")] + pub allow_partial: bool, + #[prost(string, optional, tag = "7")] + pub description: ::core::option::Option<::prost::alloc::string::String>, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct RecordLedgerWithdrawalResponse { + /// Unset when nothing was written. + #[prost(message, optional, tag = "1")] + pub entry: ::core::option::Option, + #[prost(uint64, tag = "2")] + pub withdrawn_cents: u64, + /// The part of amount_cents that the balance did not cover. The caller + /// must collect it some other way. + #[prost(uint64, tag = "3")] + pub shortfall_cents: u64, + /// The balance after the withdrawal. + #[prost(int64, tag = "4")] + pub balance_cents: i64, + #[prost(bool, tag = "5")] + pub created: bool, +} From 0f48db6cec10146c902ccccb1daee93e65185801 Mon Sep 17 00:00:00 2001 From: Volo Kluev Date: Mon, 26 Oct 2026 11:26:46 -0700 Subject: [PATCH 3/6] ref(billing): Let ledger withdrawals make the balance negative A withdrawal now always takes the full amount, so drop allow_partial and the withdrawn and shortfall fields that only it needed. --- .../endpoint_record_ledger_adjustment.proto | 2 +- .../endpoint_record_ledger_withdrawal.proto | 23 ++++++------------- .../contract/ledger/v1/ledger_entry.proto | 2 +- 3 files changed, 9 insertions(+), 18 deletions(-) diff --git a/proto/sentry_protos/billing/v1/services/contract/ledger/v1/endpoint_record_ledger_adjustment.proto b/proto/sentry_protos/billing/v1/services/contract/ledger/v1/endpoint_record_ledger_adjustment.proto index 7f5770a2..1e219d3b 100644 --- a/proto/sentry_protos/billing/v1/services/contract/ledger/v1/endpoint_record_ledger_adjustment.proto +++ b/proto/sentry_protos/billing/v1/services/contract/ledger/v1/endpoint_record_ledger_adjustment.proto @@ -6,7 +6,7 @@ import "sentry_protos/billing/v1/services/contract/ledger/v1/ledger_entry.proto" // Records a manual correction. A refund is a negative adjustment: the refunded // invoice stays paid, so its deposit still counts and the adjustment cancels -// it. Unlike a withdrawal, an adjustment can make the balance negative. +// it. message RecordLedgerAdjustmentRequest { uint64 organization_id = 1; string account_id = 2; diff --git a/proto/sentry_protos/billing/v1/services/contract/ledger/v1/endpoint_record_ledger_withdrawal.proto b/proto/sentry_protos/billing/v1/services/contract/ledger/v1/endpoint_record_ledger_withdrawal.proto index 901e0ce6..dde642e1 100644 --- a/proto/sentry_protos/billing/v1/services/contract/ledger/v1/endpoint_record_ledger_withdrawal.proto +++ b/proto/sentry_protos/billing/v1/services/contract/ledger/v1/endpoint_record_ledger_withdrawal.proto @@ -4,12 +4,12 @@ package sentry_protos.billing.v1.services.contract.ledger.v1; import "sentry_protos/billing/v1/services/contract/ledger/v1/ledger_entry.proto"; -// Takes credit out of an account. A withdrawal never makes the balance -// negative. The balance check and the write are atomic, so concurrent -// withdrawals can not spend the same credit. +// Takes credit out of an account. The full amount is always withdrawn, even +// when that makes the balance negative. message RecordLedgerWithdrawalRequest { uint64 organization_id = 1; string account_id = 2; + // Must be positive. uint64 amount_cents = 3; // Unique per organization and account_id. A repeated key writes nothing // and returns the existing entry with created=false. @@ -17,21 +17,12 @@ message RecordLedgerWithdrawalRequest { // The contract whose usage the withdrawal pays for. Defaults to the // organization's current contract. optional uint64 contract_id = 5; - // When the balance is less than amount_cents: if true, withdraw the whole - // balance and report the remainder in shortfall_cents; if false, write - // nothing and set shortfall_cents to amount_cents. - bool allow_partial = 6; - optional string description = 7; + optional string description = 6; } message RecordLedgerWithdrawalResponse { - // Unset when nothing was written. - optional LedgerEntry entry = 1; - uint64 withdrawn_cents = 2; - // The part of amount_cents that the balance did not cover. The caller - // must collect it some other way. - uint64 shortfall_cents = 3; + LedgerEntry entry = 1; + bool created = 2; // The balance after the withdrawal. - int64 balance_cents = 4; - bool created = 5; + int64 balance_cents = 3; } diff --git a/proto/sentry_protos/billing/v1/services/contract/ledger/v1/ledger_entry.proto b/proto/sentry_protos/billing/v1/services/contract/ledger/v1/ledger_entry.proto index 682f6215..ff51275e 100644 --- a/proto/sentry_protos/billing/v1/services/contract/ledger/v1/ledger_entry.proto +++ b/proto/sentry_protos/billing/v1/services/contract/ledger/v1/ledger_entry.proto @@ -23,7 +23,7 @@ message LedgerEntry { string account_id = 3; // The organization's contract when the entry was written, if it had one. optional uint64 contract_id = 4; - // Signed: deposits are positive, withdrawals are negative or zero. + // Signed: deposits are positive, withdrawals are negative. int64 amount_cents = 5; LedgerEntryType entry_type = 6; // The invoice that pays for a deposit, or that an adjustment refers to. From 2f4de25a557c7522d9e4861bf58448f47801cf0b Mon Sep 17 00:00:00 2001 From: "getsantry[bot]" <66042841+getsantry[bot]@users.noreply.github.com> Date: Mon, 26 Oct 2026 18:28:23 +0000 Subject: [PATCH 4/6] chore: Regenerate Rust bindings --- ....billing.v1.services.contract.ledger.v1.rs | 30 ++++++------------- 1 file changed, 9 insertions(+), 21 deletions(-) diff --git a/rust/src/sentry_protos.billing.v1.services.contract.ledger.v1.rs b/rust/src/sentry_protos.billing.v1.services.contract.ledger.v1.rs index 484015fa..73d608a7 100644 --- a/rust/src/sentry_protos.billing.v1.services.contract.ledger.v1.rs +++ b/rust/src/sentry_protos.billing.v1.services.contract.ledger.v1.rs @@ -29,7 +29,7 @@ pub struct LedgerEntry { /// The organization's contract when the entry was written, if it had one. #[prost(uint64, optional, tag = "4")] pub contract_id: ::core::option::Option, - /// Signed: deposits are positive, withdrawals are negative or zero. + /// Signed: deposits are positive, withdrawals are negative. #[prost(int64, tag = "5")] pub amount_cents: i64, #[prost(enumeration = "LedgerEntryType", tag = "6")] @@ -109,7 +109,7 @@ pub struct ListLedgerEntriesResponse { } /// Records a manual correction. A refund is a negative adjustment: the refunded /// invoice stays paid, so its deposit still counts and the adjustment cancels -/// it. Unlike a withdrawal, an adjustment can make the balance negative. +/// it. #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct RecordLedgerAdjustmentRequest { #[prost(uint64, tag = "1")] @@ -167,15 +167,15 @@ pub struct RecordLedgerDepositResponse { #[prost(bool, tag = "2")] pub created: bool, } -/// Takes credit out of an account. A withdrawal never makes the balance -/// negative. The balance check and the write are atomic, so concurrent -/// withdrawals can not spend the same credit. +/// Takes credit out of an account. The full amount is always withdrawn, even +/// when that makes the balance negative. #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct RecordLedgerWithdrawalRequest { #[prost(uint64, tag = "1")] pub organization_id: u64, #[prost(string, tag = "2")] pub account_id: ::prost::alloc::string::String, + /// Must be positive. #[prost(uint64, tag = "3")] pub amount_cents: u64, /// Unique per organization and account_id. A repeated key writes nothing @@ -186,28 +186,16 @@ pub struct RecordLedgerWithdrawalRequest { /// organization's current contract. #[prost(uint64, optional, tag = "5")] pub contract_id: ::core::option::Option, - /// When the balance is less than amount_cents: if true, withdraw the whole - /// balance and report the remainder in shortfall_cents; if false, write - /// nothing and set shortfall_cents to amount_cents. - #[prost(bool, tag = "6")] - pub allow_partial: bool, - #[prost(string, optional, tag = "7")] + #[prost(string, optional, tag = "6")] pub description: ::core::option::Option<::prost::alloc::string::String>, } #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct RecordLedgerWithdrawalResponse { - /// Unset when nothing was written. #[prost(message, optional, tag = "1")] pub entry: ::core::option::Option, - #[prost(uint64, tag = "2")] - pub withdrawn_cents: u64, - /// The part of amount_cents that the balance did not cover. The caller - /// must collect it some other way. - #[prost(uint64, tag = "3")] - pub shortfall_cents: u64, + #[prost(bool, tag = "2")] + pub created: bool, /// The balance after the withdrawal. - #[prost(int64, tag = "4")] + #[prost(int64, tag = "3")] pub balance_cents: i64, - #[prost(bool, tag = "5")] - pub created: bool, } From fee3981caffefe9d01a45f38aa9fea717ed0607b Mon Sep 17 00:00:00 2001 From: Volo Kluev Date: Mon, 26 Oct 2026 14:37:12 -0700 Subject: [PATCH 5/6] feat(billing): Write ledger deposits with their off-cycle invoice A caller that billed an off-cycle invoice and then recorded the deposit in a second call could crash in between, leaving a customer charged without credit. Add a required LedgerDeposit to CreateOffCycleInvoiceRequest and BillOffCycleInvoiceRequest so the deposit is written in the same transaction as the invoice, and remove RecordLedgerDeposit. --- .../v1/endpoint_record_ledger_deposit.proto | 25 ------------------- .../contract/ledger/v1/ledger_deposit.proto | 16 ++++++++++++ .../endpoint_create_off_cycle_invoice.proto | 4 +++ .../v1/endpoint_bill_off_cycle_invoice.proto | 4 +++ 4 files changed, 24 insertions(+), 25 deletions(-) delete mode 100644 proto/sentry_protos/billing/v1/services/contract/ledger/v1/endpoint_record_ledger_deposit.proto create mode 100644 proto/sentry_protos/billing/v1/services/contract/ledger/v1/ledger_deposit.proto diff --git a/proto/sentry_protos/billing/v1/services/contract/ledger/v1/endpoint_record_ledger_deposit.proto b/proto/sentry_protos/billing/v1/services/contract/ledger/v1/endpoint_record_ledger_deposit.proto deleted file mode 100644 index 052d7bd2..00000000 --- a/proto/sentry_protos/billing/v1/services/contract/ledger/v1/endpoint_record_ledger_deposit.proto +++ /dev/null @@ -1,25 +0,0 @@ -syntax = "proto3"; - -package sentry_protos.billing.v1.services.contract.ledger.v1; - -import "sentry_protos/billing/v1/services/contract/ledger/v1/ledger_entry.proto"; - -// Records credit bought through an off-cycle invoice. The balance excludes -// the deposit until the invoice is paid, so record it as soon as the invoice -// exists. An invoice holds at most one deposit: a repeated request writes -// nothing and returns the existing entry with created=false. -message RecordLedgerDepositRequest { - uint64 organization_id = 1; - string account_id = 2; - // Must belong to organization_id. - uint64 invoice_id = 3; - // The credit bought, excluding tax. Must be positive and at most the - // invoice amount. - uint64 amount_cents = 4; - optional string description = 5; -} - -message RecordLedgerDepositResponse { - LedgerEntry entry = 1; - bool created = 2; -} diff --git a/proto/sentry_protos/billing/v1/services/contract/ledger/v1/ledger_deposit.proto b/proto/sentry_protos/billing/v1/services/contract/ledger/v1/ledger_deposit.proto new file mode 100644 index 00000000..b41456ac --- /dev/null +++ b/proto/sentry_protos/billing/v1/services/contract/ledger/v1/ledger_deposit.proto @@ -0,0 +1,16 @@ +syntax = "proto3"; + +package sentry_protos.billing.v1.services.contract.ledger.v1; + +// Credit that an off-cycle invoice buys. The deposit is written in the same +// transaction as the invoice, so a crash can not leave a charge without its +// credit. It counts toward the balance once the invoice is paid. +message LedgerDeposit { + // 1 to 64 characters. + string account_id = 1; + // The credit bought. Must be positive and at most the invoice's total + // before tax. + uint64 amount_cents = 2; + // At most 256 characters. + optional string description = 3; +} diff --git a/proto/sentry_protos/billing/v1/services/contract/v2/endpoint_create_off_cycle_invoice.proto b/proto/sentry_protos/billing/v1/services/contract/v2/endpoint_create_off_cycle_invoice.proto index fb1aa957..6d7ac7e7 100644 --- a/proto/sentry_protos/billing/v1/services/contract/v2/endpoint_create_off_cycle_invoice.proto +++ b/proto/sentry_protos/billing/v1/services/contract/v2/endpoint_create_off_cycle_invoice.proto @@ -3,6 +3,7 @@ syntax = "proto3"; package sentry_protos.billing.v1.services.contract.v2; import "sentry_protos/billing/v1/common/v1/address.proto"; +import "sentry_protos/billing/v1/services/contract/ledger/v1/ledger_deposit.proto"; import "sentry_protos/billing/v1/services/contract/v1/invoice.proto"; // Writes an organization-scoped invoice outside of any contract, ready for the @@ -29,6 +30,9 @@ message CreateOffCycleInvoiceRequest { // The tax document the provider opened for this invoice, if any. optional string tax_transaction_code = 8; bool is_reverse_charge = 9; + // Required. The credit the invoice buys. A repeated idempotency_key returns + // the existing invoice and writes no second deposit. + sentry_protos.billing.v1.services.contract.ledger.v1.LedgerDeposit ledger_deposit = 10; } message CreateOffCycleInvoiceResponse { diff --git a/proto/sentry_protos/billing/v1/services/invoicer/v1/endpoint_bill_off_cycle_invoice.proto b/proto/sentry_protos/billing/v1/services/invoicer/v1/endpoint_bill_off_cycle_invoice.proto index 1ec0333c..891fd7a6 100644 --- a/proto/sentry_protos/billing/v1/services/invoicer/v1/endpoint_bill_off_cycle_invoice.proto +++ b/proto/sentry_protos/billing/v1/services/invoicer/v1/endpoint_bill_off_cycle_invoice.proto @@ -3,6 +3,7 @@ syntax = "proto3"; package sentry_protos.billing.v1.services.invoicer.v1; import "sentry_protos/billing/v1/common/v1/stripe_verification.proto"; +import "sentry_protos/billing/v1/services/contract/ledger/v1/ledger_deposit.proto"; import "sentry_protos/billing/v1/services/contract/v1/invoice.proto"; // Bills an organization for line items another service has priced, outside of @@ -26,6 +27,9 @@ message BillOffCycleInvoiceRequest { // requesting service chooses: FAILURE_POLICY_RETRY_ONLY leaves account // status alone and makes the service responsible for non-payment. sentry_protos.billing.v1.services.contract.v1.FailurePolicy failure_policy = 6; + // Required. The credit the invoice buys. The deposit exists before the + // charge starts. + sentry_protos.billing.v1.services.contract.ledger.v1.LedgerDeposit ledger_deposit = 7; } message BillOffCycleInvoiceResponse { From 2070ff170ae1a1252383095332ffdb7fbb916f4e Mon Sep 17 00:00:00 2001 From: "getsantry[bot]" <66042841+getsantry[bot]@users.noreply.github.com> Date: Mon, 26 Oct 2026 21:38:03 +0000 Subject: [PATCH 6/6] chore: Regenerate Rust bindings --- ....billing.v1.services.contract.ledger.v1.rs | 43 +++++++------------ ..._protos.billing.v1.services.contract.v2.rs | 4 ++ ..._protos.billing.v1.services.invoicer.v1.rs | 6 +++ 3 files changed, 26 insertions(+), 27 deletions(-) diff --git a/rust/src/sentry_protos.billing.v1.services.contract.ledger.v1.rs b/rust/src/sentry_protos.billing.v1.services.contract.ledger.v1.rs index 73d608a7..94008824 100644 --- a/rust/src/sentry_protos.billing.v1.services.contract.ledger.v1.rs +++ b/rust/src/sentry_protos.billing.v1.services.contract.ledger.v1.rs @@ -140,33 +140,6 @@ pub struct RecordLedgerAdjustmentResponse { #[prost(int64, tag = "3")] pub balance_cents: i64, } -/// Records credit bought through an off-cycle invoice. The balance excludes -/// the deposit until the invoice is paid, so record it as soon as the invoice -/// exists. An invoice holds at most one deposit: a repeated request writes -/// nothing and returns the existing entry with created=false. -#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] -pub struct RecordLedgerDepositRequest { - #[prost(uint64, tag = "1")] - pub organization_id: u64, - #[prost(string, tag = "2")] - pub account_id: ::prost::alloc::string::String, - /// Must belong to organization_id. - #[prost(uint64, tag = "3")] - pub invoice_id: u64, - /// The credit bought, excluding tax. Must be positive and at most the - /// invoice amount. - #[prost(uint64, tag = "4")] - pub amount_cents: u64, - #[prost(string, optional, tag = "5")] - pub description: ::core::option::Option<::prost::alloc::string::String>, -} -#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] -pub struct RecordLedgerDepositResponse { - #[prost(message, optional, tag = "1")] - pub entry: ::core::option::Option, - #[prost(bool, tag = "2")] - pub created: bool, -} /// Takes credit out of an account. The full amount is always withdrawn, even /// when that makes the balance negative. #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] @@ -199,3 +172,19 @@ pub struct RecordLedgerWithdrawalResponse { #[prost(int64, tag = "3")] pub balance_cents: i64, } +/// Credit that an off-cycle invoice buys. The deposit is written in the same +/// transaction as the invoice, so a crash can not leave a charge without its +/// credit. It counts toward the balance once the invoice is paid. +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct LedgerDeposit { + /// 1 to 64 characters. + #[prost(string, tag = "1")] + pub account_id: ::prost::alloc::string::String, + /// The credit bought. Must be positive and at most the invoice's total + /// before tax. + #[prost(uint64, tag = "2")] + pub amount_cents: u64, + /// At most 256 characters. + #[prost(string, optional, tag = "3")] + pub description: ::core::option::Option<::prost::alloc::string::String>, +} diff --git a/rust/src/sentry_protos.billing.v1.services.contract.v2.rs b/rust/src/sentry_protos.billing.v1.services.contract.v2.rs index 2e919a67..6516dd7c 100644 --- a/rust/src/sentry_protos.billing.v1.services.contract.v2.rs +++ b/rust/src/sentry_protos.billing.v1.services.contract.v2.rs @@ -255,6 +255,10 @@ pub struct CreateOffCycleInvoiceRequest { pub tax_transaction_code: ::core::option::Option<::prost::alloc::string::String>, #[prost(bool, tag = "9")] pub is_reverse_charge: bool, + /// Required. The credit the invoice buys. A repeated idempotency_key returns + /// the existing invoice and writes no second deposit. + #[prost(message, optional, tag = "10")] + pub ledger_deposit: ::core::option::Option, } #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct CreateOffCycleInvoiceResponse { 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 c6ea3659..ba68e086 100644 --- a/rust/src/sentry_protos.billing.v1.services.invoicer.v1.rs +++ b/rust/src/sentry_protos.billing.v1.services.invoicer.v1.rs @@ -68,6 +68,12 @@ pub struct BillOffCycleInvoiceRequest { /// status alone and makes the service responsible for non-payment. #[prost(enumeration = "super::super::contract::v1::FailurePolicy", tag = "6")] pub failure_policy: i32, + /// Required. The credit the invoice buys. The deposit exists before the + /// charge starts. + #[prost(message, optional, tag = "7")] + pub ledger_deposit: ::core::option::Option< + super::super::contract::ledger::v1::LedgerDeposit, + >, } #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct BillOffCycleInvoiceResponse {