diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c1f6194..a1bce24 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,10 +16,10 @@ jobs: include: - build: macos os: macos-latest - rust: 1.67.0 + rust: 1.70.0 - build: ubuntu os: ubuntu-latest - rust: 1.67.0 + rust: 1.70.0 steps: - uses: actions/checkout@v2 - uses: actions-rs/toolchain@v1 @@ -36,7 +36,7 @@ jobs: - uses: actions/checkout@v2 - uses: actions-rs/toolchain@v1 with: - toolchain: 1.67.0 + toolchain: 1.70.0 default: true components: rustfmt - run: cargo fmt -- --check @@ -47,7 +47,7 @@ jobs: - uses: actions/checkout@v2 - uses: actions-rs/toolchain@v1 with: - toolchain: 1.67.0 + toolchain: 1.70.0 default: true components: clippy - uses: actions-rs/clippy-check@v1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 843aaf7..bbb9495 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,9 @@ Versioning]. ## [Unreleased] * Add support for Plan metadata. -* Bump MSRV to 1.67. +* Add support for creating customer ledger entries. +* Add support for enumerating customer credit balances. +* Bump MSRV to 1.70. ## [0.6.0] - 2023-05-12 diff --git a/Cargo.toml b/Cargo.toml index 153013f..2c05cf2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,7 @@ categories = ["api-bindings", "web-programming"] keywords = ["orb", "billing", "api", "sdk"] repository = "https://github.com/MaterializeInc/rust-orb-billing" version = "0.6.0" -rust-version = "1.67" +rust-version = "1.70" edition = "2021" [dependencies] diff --git a/src/client/customers.rs b/src/client/customers.rs index 6fed6ea..e8d7934 100644 --- a/src/client/customers.rs +++ b/src/client/customers.rs @@ -235,6 +235,203 @@ pub struct Address { pub state: Option, } +/// The types of ledger entries that can be created. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)] +#[serde(tag = "entry_type")] +pub enum LedgerEntryRequest<'a> { + /// Increment a credit balance + #[serde(rename = "increment")] + Increment(AddIncrementCreditLedgerEntryRequestParams<'a>), + /// Void an existing ledger entry + #[serde(rename = "void")] + Void(AddVoidCreditLedgerEntryRequestParams<'a>), + // TODO: additional ledger entry types +} + +/// Optional invoicing settings for a credit purchase. +#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, Serialize)] +pub struct CreditLedgerInvoiceSettingsRequestParams<'a> { + /// Whether the credits purchase invoice should auto collect with the customer's saved payment + /// method. + pub auto_collection: bool, + /// The difference between the invoice date and the issue date for the invoice. If due on issue, + /// set this to `0`. + pub net_terms: u64, + /// An optional memo to display on the invoice + #[serde(skip_serializing_if = "Option::is_none")] + pub memo: Option<&'a str>, +} + +/// The parameters used to create a customer credit ledger entry. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)] +pub struct AddIncrementCreditLedgerEntryRequestParams<'a> { + /// The amount to credit the customer for. + pub amount: serde_json::Number, + /// An optional description for the credit operation. + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option<&'a str>, + /// The date on which the block's balance will expire. + #[serde(with = "time::serde::rfc3339::option")] + #[serde(skip_serializing_if = "Option::is_none")] + pub expiry_date: Option, + /// The date on which the block's balance will become available for use. + #[serde(with = "time::serde::rfc3339::option")] + #[serde(skip_serializing_if = "Option::is_none")] + pub effective_date: Option, + /// The price per credit. + #[serde(skip_serializing_if = "Option::is_none")] + pub per_unit_cost_basis: Option<&'a str>, + /// Invoicing settings for the credit increment request. + #[serde(skip_serializing_if = "Option::is_none")] + pub invoice_settings: Option>, +} + +/// The reason for a void operation. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize_enum_str, Serialize_enum_str)] +pub enum VoidReason { + /// The credits are being returned to the originator. + #[serde(rename = "refund")] + Refund, +} + +/// The parameters used to void a customer credit ledger entry. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)] +pub struct AddVoidCreditLedgerEntryRequestParams<'a> { + /// The number of credits to void. + pub amount: serde_json::Number, + /// The ID of the credit ledger block to void. + pub block_id: &'a str, + /// An optional reason for the void. + #[serde(skip_serializing_if = "Option::is_none")] + pub void_reason: Option, + /// An optional description for the void operation. + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option<&'a str>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize)] +pub struct CustomerCreditBlock { + /// The Orb-assigned unique identifier for the credit block. + pub id: String, + /// The remaining credit balance for the block. + pub balance: serde_json::Number, + /// The date on which the block's balance will expire. + #[serde(with = "time::serde::rfc3339::option")] + pub expiry_date: Option, + /// The price per credit. + pub per_unit_cost_basis: Option, +} + +/// The type of ledger entry +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(tag = "entry_type")] +pub enum LedgerEntry { + /// Incrementing a credit balance + #[serde(rename = "increment")] + Increment(IncrementLedgerEntry), + /// Voiding of an existing ledger entry + #[serde(rename = "void")] + Void(VoidLedgerEntry), + /// Voiding of an existing ledger entry has been initiated + #[serde(rename = "void_initiated")] + VoidInitiated(VoidInitiatedLedgerEntry), + // TODO: additional ledger entry types +} + +/// The state of a ledger entry +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize_enum_str)] +pub enum EntryStatus { + /// The entry has been committed to the ledger + #[serde(rename = "committed")] + Committed, + /// The entry hasn't yet been committed to the ledger + #[serde(rename = "pending")] + Pending, +} + +/// A collection of identifiers for a customer +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct CustomerIdentifier { + /// The Orb-assigned unique identifier for the customer. + pub id: String, + /// An optional user-defined ID for this customer resource, used throughout + /// the system as an alias for this customer. + pub external_customer_id: Option, +} + +/// Credit block data associated with entries in a ledger. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct LedgerEntryCreditBlock { + //// The Orb-assigned unique identifier for the credit block. + pub id: String, + /// The date on which the block's balance will expire. + #[serde(with = "time::serde::rfc3339::option")] + pub expiry_date: Option, + /// The price per credit. + pub per_unit_cost_basis: Option, +} + +/// Core ledger entry fields. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct BaseLedgerEntry { + /// The Orb-assigned unique identifier for the ledger entry. + pub id: String, + /// An incrementing identifier ordering the ledger entry relative to others. + pub ledger_sequence_number: u64, + /// The state of the ledger entry. + pub entry_status: EntryStatus, + /// The customer identifiers associated with the ledger entry. + pub customer: CustomerIdentifier, + /// The customer's credit balance before application of the ledger operation. + pub starting_balance: serde_json::Number, + /// The customer's credit balance after application of the ledger operation. + pub ending_balance: serde_json::Number, + /// The amount granted to the ledger. + pub amount: serde_json::Number, + /// The date the ledger entry was created. + #[serde(with = "time::serde::rfc3339")] + pub created_at: OffsetDateTime, + /// An optional description to associate with the entry. + pub description: Option, + /// The credit block the ledger entry is modifying. + pub credit_block: LedgerEntryCreditBlock, +} + +/// A record of an ledger increment operation. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct IncrementLedgerEntry { + /// The core ledger entry. + #[serde(flatten)] + pub ledger: BaseLedgerEntry, +} + +/// A record of a ledger void operation. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct VoidLedgerEntry { + /// The core ledger entry. + #[serde(flatten)] + pub ledger: BaseLedgerEntry, + /// The reason the ledger entry was voided. + pub void_reason: Option, + /// The amount voided from the ledger. + pub void_amount: serde_json::Number, +} + +/// A record of a ledger void initialization operation. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct VoidInitiatedLedgerEntry { + /// The core ledger entry. + #[serde(flatten)] + pub ledger: BaseLedgerEntry, + /// The date on which the voided ledger's block will now expire. + #[serde(with = "time::serde::rfc3339")] + pub new_block_expiry_date: OffsetDateTime, + /// The reason the ledger entry was voided. + pub void_reason: Option, + /// The amount voided from the ledger. + pub void_amount: serde_json::Number, +} + impl Client { /// Lists all customers. /// @@ -333,4 +530,57 @@ impl Client { let _: Empty = self.send_request(req).await?; Ok(()) } + + /// Fetch all unexpired, non-zero credit blocks for a customer. + /// + /// The underlying API call is paginated. The returned stream will fetch + /// additional pages as it is consumed. + pub fn get_customer_credit_balance( + &self, + id: &str, + params: &ListParams, + ) -> impl Stream> + '_ { + let req = self.build_request( + Method::GET, + CUSTOMERS_PATH.chain_one(id).chain_one("credits"), + ); + self.stream_paginated_request(params, req) + } + + /// Fetch all unexpired, non-zero credit blocks for a customer by external ID. + /// + /// The underlying API call is paginated. The returned stream will fetch + /// additional pages as it is consumed. + pub fn get_customer_credit_balance_by_external_id( + &self, + external_id: &str, + params: &ListParams, + ) -> impl Stream> + '_ { + let req = self.build_request( + Method::GET, + CUSTOMERS_PATH + .chain_one("external_customer_id") + .chain_one(external_id) + .chain_one("credits"), + ); + self.stream_paginated_request(params, req) + } + + /// Ceate a new ledger entry for the specified customer's balance. + pub async fn create_ledger_entry( + &self, + id: &str, + entry: &LedgerEntryRequest<'_>, + ) -> Result { + let req = self.build_request( + Method::POST, + CUSTOMERS_PATH + .chain_one(id) + .chain_one("credits") + .chain_one("ledger_entry"), + ); + let req = req.json(entry); + let res = self.send_request(req).await?; + Ok(res) + } } diff --git a/src/lib.rs b/src/lib.rs index 0cf5c28..2275f88 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -43,8 +43,9 @@ mod serde; mod util; pub use client::customers::{ - Address, AddressRequest, CreateCustomerRequest, Customer, CustomerId, - CustomerPaymentProviderRequest, PaymentProvider, UpdateCustomerRequest, + AddIncrementCreditLedgerEntryRequestParams, AddVoidCreditLedgerEntryRequestParams, Address, + AddressRequest, CreateCustomerRequest, Customer, CustomerId, CustomerPaymentProviderRequest, + LedgerEntry, LedgerEntryRequest, PaymentProvider, UpdateCustomerRequest, VoidReason, }; pub use client::events::{ AmendEventRequest, Event, EventPropertyValue, EventSearchParams, IngestEventDebugResponse, diff --git a/tests/api.rs b/tests/api.rs index 631f6f0..41c8734 100644 --- a/tests/api.rs +++ b/tests/api.rs @@ -40,11 +40,12 @@ use tokio::time::{self, Duration}; use tracing::info; use orb_billing::{ - Address, AddressRequest, AmendEventRequest, Client, ClientConfig, CreateCustomerRequest, + AddIncrementCreditLedgerEntryRequestParams, AddVoidCreditLedgerEntryRequestParams, Address, + AddressRequest, AmendEventRequest, Client, ClientConfig, CreateCustomerRequest, CreateSubscriptionRequest, Customer, CustomerId, CustomerPaymentProviderRequest, Error, Event, EventPropertyValue, EventSearchParams, IngestEventRequest, IngestionMode, InvoiceListParams, - ListParams, PaymentProvider, SubscriptionListParams, TaxId, TaxIdRequest, - UpdateCustomerRequest, + LedgerEntry, LedgerEntryRequest, ListParams, PaymentProvider, SubscriptionListParams, TaxId, + TaxIdRequest, UpdateCustomerRequest, VoidReason, }; /// The API key to authenticate with. @@ -153,6 +154,58 @@ async fn test_customers() { assert_eq!(customer.name, name); assert_eq!(customer.email, email); + // Test crediting customers and reading their balances back + let ledger_res = client + .create_ledger_entry( + &customer.id, + &LedgerEntryRequest::Increment(AddIncrementCreditLedgerEntryRequestParams { + amount: serde_json::Number::from(42), + description: Some("Test credit"), + expiry_date: None, + effective_date: None, + per_unit_cost_basis: None, + invoice_settings: None, + }), + ) + .await + .unwrap(); + let inc_res = match ledger_res { + LedgerEntry::Increment(inc_res) => inc_res, + entry => panic!("Expected an Increment, received: {:?}", entry), + }; + assert_eq!(inc_res.ledger.customer.id, customer.id); + let balance: Vec<_> = client + .get_customer_credit_balance(&customer.id, &ListParams::default().page_size(1)) + .try_collect() + .await + .unwrap(); + assert_eq!(balance.get(0).unwrap().balance, inc_res.ledger.amount); + let ledger_res = client + .create_ledger_entry( + &customer.id, + &LedgerEntryRequest::Void(AddVoidCreditLedgerEntryRequestParams { + amount: inc_res.ledger.amount, + block_id: &inc_res.ledger.credit_block.id, + void_reason: Some(VoidReason::Refund), + description: None, + }), + ) + .await + .unwrap(); + let void_res = match ledger_res { + LedgerEntry::VoidInitiated(void_res) => void_res, + entry => panic!("Expected a VoidInitiated, received a {:?}", entry), + }; + assert_eq!(void_res.ledger.customer.id, customer.id); + let balance: Vec<_> = client + .get_customer_credit_balance_by_external_id( + &customer.external_id.unwrap(), + &ListParams::default().page_size(1), + ) + .try_collect() + .await + .unwrap(); + assert!(balance.is_empty()); // Test a second creation request with the same idempotency key does // *not* create a new instance let res = client