Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Customer credit ledger entry creation, and customer credit enumeration #18

Merged
merged 3 commits into from
Nov 29, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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")]
guswynn marked this conversation as resolved.
Show resolved Hide resolved
#[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)]
arusahni marked this conversation as resolved.
Show resolved Hide resolved
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,
guswynn marked this conversation as resolved.
Show resolved Hide resolved
/// 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>,
guswynn marked this conversation as resolved.
Show resolved Hide resolved
}

/// 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>,
guswynn marked this conversation as resolved.
Show resolved Hide resolved
/// 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
Loading