Skip to content

Commit

Permalink
Merge pull request #18 from MaterializeInc/feat/customer-credit-ops
Browse files Browse the repository at this point in the history
Customer credit ledger entry creation, and customer credit enumeration
  • Loading branch information
benesch authored Nov 29, 2023
2 parents e0b93b0 + 99ce79e commit 28cd924
Show file tree
Hide file tree
Showing 6 changed files with 317 additions and 11 deletions.
8 changes: 4 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ Versioning].
## [Unreleased] <!-- #release:date -->

* 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

Expand Down
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
250 changes: 250 additions & 0 deletions src/client/customers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,203 @@ pub struct Address {
pub state: Option<String>,
}

/// 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<OffsetDateTime>,
/// 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<OffsetDateTime>,
/// 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<CreditLedgerInvoiceSettingsRequestParams<'a>>,
}

/// 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<VoidReason>,
/// 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<OffsetDateTime>,
/// The price per credit.
pub per_unit_cost_basis: Option<String>,
}

/// 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<String>,
}

/// 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<OffsetDateTime>,
/// The price per credit.
pub per_unit_cost_basis: Option<String>,
}

/// 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<String>,
/// 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<String>,
/// 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<String>,
/// The amount voided from the ledger.
pub void_amount: serde_json::Number,
}

impl Client {
/// Lists all customers.
///
Expand Down Expand Up @@ -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<Item = Result<CustomerCreditBlock, Error>> + '_ {
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<Item = Result<CustomerCreditBlock, Error>> + '_ {
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<LedgerEntry, Error> {
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)
}
}
5 changes: 3 additions & 2 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
59 changes: 56 additions & 3 deletions tests/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down

0 comments on commit 28cd924

Please sign in to comment.