diff --git a/src/client/customers.rs b/src/client/customers.rs index f41bf0e..7ef56a9 100644 --- a/src/client/customers.rs +++ b/src/client/customers.rs @@ -265,7 +265,7 @@ pub enum LedgerEntryRequest<'a> { } /// Optional invoicing settings for a credit purchase. -#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, Serialize)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct CreditLedgerInvoiceSettingsRequestParams<'a> { /// Whether the credits purchase invoice should auto collect with the customer's saved payment /// method. @@ -282,6 +282,25 @@ pub struct CreditLedgerInvoiceSettingsRequestParams<'a> { pub require_successful_payment: Option, } +/// Optional invoicing settings for a credit purchase. +/// Unlike CreditLedgerInvoiceSettingsRequestParams, this struct is owned. +#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct CreditLedgerInvoiceSettings { + /// 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, + /// Whether the credits should be withheld from the customer account until the invoice is paid. + /// This applies primarily to stripe invoicing. + #[serde(skip_serializing_if = "Option::is_none")] + pub require_successful_payment: Option, +} + /// The parameters used to create a customer credit ledger entry. #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)] pub struct AddIncrementCreditLedgerEntryRequestParams<'a> { @@ -341,6 +360,21 @@ pub struct CustomerCreditBlock { pub expiry_date: Option, /// The price per credit. pub per_unit_cost_basis: Option, + /// The status of the credit block. Credit blocks are initialized into `pending_payment` + /// when require_successful_payment is set in [`CreditLedgerInvoiceSettings`]. + pub status: CreditBlockStatus, +} + +/// The status of the credit block. Credit blocks are initialized into `pending_payment` +/// when require_successful_payment is set in [`CreditLedgerInvoiceSettings`]. +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Deserialize, Serialize)] +pub enum CreditBlockStatus { + /// A credit block that should count to the total balance. + #[serde(rename = "active")] + Active, + /// A credit block which hasn't been marked as paid. + #[serde(rename = "pending_payment")] + PendingPayment, } /// The type of ledger entry @@ -356,7 +390,19 @@ pub enum LedgerEntry { /// Voiding of an existing ledger entry has been initiated #[serde(rename = "void_initiated")] VoidInitiated(VoidInitiatedLedgerEntry), - // TODO: additional ledger entry types + /// Expiration change + #[serde(rename = "expiration_change")] + ExpirationChange(BaseLedgerEntry), + /// Credit block expiry + #[serde(rename = "credit_block_expiry")] + CreditBlockExpiry(BaseLedgerEntry), + /// Decrement + #[serde(rename = "decrement")] + DecrementLedgerEntry(BaseLedgerEntry), + /// Amendment + #[serde(rename = "amendment")] + Amendment(BaseLedgerEntry), + // TODO: Keep up to date with https://docs.withorb.com/reference/create-ledger-entry } /// The state of a ledger entry @@ -464,12 +510,58 @@ pub enum CostViewMode { Cumulative, } +/// The filters applied to the customer costs query. #[derive(Debug, Default, Clone)] -struct CustomerCostParamsFilter<'a> { - timeframe_start: Option<&'a OffsetDateTime>, - timeframe_end: Option<&'a OffsetDateTime>, - view_mode: Option, - group_by: Option<&'a str>, +pub struct CustomerCostParamsFilter<'a> { + /// The start of the returned range. If not specified this defaults to the billing period start + /// date. + pub timeframe_start: Option<&'a OffsetDateTime>, + /// The start of the returned range. If not specified this defaults to the billing period end + /// date. + pub timeframe_end: Option<&'a OffsetDateTime>, + /// How costs should be broken down in the resultant day-by-day view. + pub view_mode: Option, + /// The custom attribute to group costs by. + pub group_by: Option<&'a str>, +} + +/// Configures automatic payments for the customer +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)] +pub struct CreateTopUpRequest<'a> { + /// The currency used for this topup. + pub currency: CurrencyCode, + /// The threshold at which to initiate the topup payment. + pub threshold: &'a str, + /// The amount that should be purchase. + pub amount: &'a str, + /// The cost basis of the credits purchase. + pub per_unit_cost_basis: &'a str, + /// Additional settings for configuring the invoice + pub invoice_settings: Option>, +} + +/// The response for a user's listing topups. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct ListTopUpsResponse { + /// The list of topups. + pub data: Vec, +} + +/// Configures an external payment or invoicing solution for a customer. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct TopUp { + /// The Orb-assigned ID of this topup. + pub id: String, + /// The currency used for this topup. + pub currency: CurrencyCode, + /// The threshold at which to initiate the topup payment. + pub threshold: String, + /// The amount that should be purchase. + pub amount: String, + /// The cost basis of the credits purchase. + pub per_unit_cost_basis: String, + /// Additional settings for configuring the invoice + pub invoice_settings: Option, } trait Filterable { @@ -513,7 +605,8 @@ impl Filterable> for RequestBuilder { /// Parameters for a Customer Costs query. #[derive(Debug, Default, Clone)] pub struct CustomerCostParams<'a> { - filter: CustomerCostParamsFilter<'a>, + /// The filters applied to the customer costs query. + pub filter: CustomerCostParamsFilter<'a>, } impl<'a> CustomerCostParams<'a> { @@ -820,6 +913,24 @@ impl Client { self.send_request(req).await } + /// Create a new ledger entry for the specified customer's balance by external ID + pub async fn create_ledger_entry_by_external_id( + &self, + external_id: &str, + entry: &LedgerEntryRequest<'_>, + ) -> Result { + let req = self.build_request( + Method::POST, + CUSTOMERS_PATH + .chain_one("external_customer_id") + .chain_one(external_id) + .chain_one("credits") + .chain_one("ledger_entry"), + ); + let req = req.json(entry); + self.send_request(req).await + } + /// Fetch a day-by-day snapshot of a customer's costs. pub async fn get_customer_costs( &self, @@ -849,4 +960,109 @@ impl Client { let res: ArrayResponse = self.send_request(req).await?; Ok(res.data) } + + /// List top-ups for a customer. There is a maximum of one topup per currency, so in most + /// cases, pagination is not important. + pub async fn get_customer_topup(&self, id: &str) -> Result { + let req = self.build_request( + Method::GET, + CUSTOMERS_PATH + .chain_one(id) + .chain_one("credits") + .chain_one("top_ups"), + ); + let res: ListTopUpsResponse = self.send_request(req).await?; + Ok(res) + } + + /// List top-ups for a customer by external id. There is a maximum of one topup per currency. + pub async fn get_customer_topup_by_external_id( + &self, + external_id: &str, + ) -> Result { + let req = self.build_request( + Method::GET, + CUSTOMERS_PATH + .chain_one("external_customer_id") + .chain_one(external_id) + .chain_one("credits") + .chain_one("top_ups"), + ); + let res: ListTopUpsResponse = self.send_request(req).await?; + Ok(res) + } + + /// Create top-up for a customer. There is a maximum of one topup per currency. + pub async fn create_customer_topup<'a>( + &self, + id: &str, + body: CreateTopUpRequest<'a>, + ) -> Result { + let req = self.build_request( + Method::POST, + CUSTOMERS_PATH + .chain_one(id) + .chain_one("credits") + .chain_one("top_ups"), + ); + let req: RequestBuilder = req.json(&body); + let res: TopUp = self.send_request(req).await?; + Ok(res) + } + + /// Create top-up for a customer by external id. There is a maximum of one topup per currency. + pub async fn create_customer_topup_by_external_id<'a>( + &self, + external_id: &str, + body: CreateTopUpRequest<'a>, + ) -> Result { + let req = self.build_request( + Method::POST, + CUSTOMERS_PATH + .chain_one("external_customer_id") + .chain_one(external_id) + .chain_one("credits") + .chain_one("top_ups"), + ); + let req: RequestBuilder = req.json(&body); + let res: TopUp = self.send_request(req).await?; + Ok(res) + } + + /// Create top-up for a customer. There is a maximum of one topup per currency. + pub async fn delete_customer_topup<'a>( + &self, + user_id: &str, + top_up_id: &str, + ) -> Result<(), Error> { + let req = self.build_request( + Method::DELETE, + CUSTOMERS_PATH + .chain_one(user_id) + .chain_one("credits") + .chain_one("top_ups") + .chain_one(top_up_id), + ); + let _: Empty = self.send_request(req).await?; + Ok(()) + } + + /// Create top-up for a customer by external id. There is a maximum of one topup per currency. + pub async fn delete_customer_topup_by_external_id<'a>( + &self, + user_external_id: &str, + top_up_id: &str, + ) -> Result<(), Error> { + let req = self.build_request( + Method::DELETE, + CUSTOMERS_PATH + .chain_one("external_customer_id") + .chain_one(user_external_id) + .chain_one("credits") + .chain_one("top_ups") + .chain_one(top_up_id), + ); + let _: Empty = self.send_request(req).await?; + Ok(()) + } } diff --git a/src/client/invoices.rs b/src/client/invoices.rs index 7ee9ab3..ac9ad07 100644 --- a/src/client/invoices.rs +++ b/src/client/invoices.rs @@ -18,7 +18,7 @@ use std::collections::BTreeMap; use futures_core::Stream; use reqwest::Method; use serde::{Deserialize, Serialize}; -use time::OffsetDateTime; +use time::{Date, OffsetDateTime}; use crate::client::customers::CustomerId; use crate::client::Client; @@ -38,8 +38,9 @@ pub struct Invoice { /// The subscription associated with this invoice. pub subscription: Option, /// The issue date of the invoice. - #[serde(with = "time::serde::rfc3339")] - pub invoice_date: OffsetDateTime, + #[serde(with = "time::serde::rfc3339::option")] + #[serde(default)] + pub invoice_date: Option, /// An automatically generated number to help track and reconcile invoices. pub invoice_number: String, /// The link to download the PDF representation of the invoice. @@ -66,9 +67,19 @@ pub struct Invoice { /// values. #[serde(default)] pub metadata: BTreeMap, + /// The breakdown of prices in this invoice + pub line_items: Vec, // TODO: many missing fields. } +#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize)] +pub struct InvoiceLineItem { + /// The name of the price associated with this line item. + pub name: String, + /// The line amount before any line item-specific discounts or minimums. + pub subtotal: String, +} + /// Identifies the customer associated with an [`Invoice`]. #[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize)] pub struct InvoiceCustomer { @@ -122,6 +133,21 @@ impl InvoiceStatusFilter { }; } +/// https://docs.withorb.com/reference/mark-invoice-as-paid +/// This endpoint allows an invoice's status to be set the paid status. +/// This can only be done to invoices that are in the issued status. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)] +pub struct MarkInvoiceAsPaidBody<'a> { + /// A date string to specify the date of the payment. + pub payment_received_date: Date, + + /// An optional external ID to associate with the payment. + pub external_id: Option<&'a str>, + + /// An optional note to associate with the payment. + pub notes: Option<&'a str>, +} + /// Parameters for a subscription list operation. #[derive(Debug, Clone)] pub struct InvoiceListParams<'a> { @@ -219,10 +245,42 @@ impl Client { /// Gets an invoice by ID. pub async fn get_invoice(&self, id: &str) -> Result { let req = self.build_request(Method::GET, INVOICES.chain_one(id)); + let res: Invoice = self.send_request(req).await?; + Ok(res) + } + + /// Fetches the upcoming invoice for the current billing period given a subscription. + pub async fn get_upcoming_invoice(&self, subscription_id: &str) -> Result { + let req = self + .build_request(Method::GET, INVOICES.chain_one("upcoming")) + .query(&[("subscription_id", subscription_id)]); + let res: Invoice = self.send_request(req).await?; + Ok(res) + } + + /// Mark an invoice as paid. For example, this can be done in response to + /// Stripe's invoice paid webhook. + pub async fn mark_invoice_as_paid<'a>( + &self, + id: &str, + body: MarkInvoiceAsPaidBody<'a>, + ) -> Result { + let req = self.build_request(Method::POST, INVOICES.chain_one(id).chain_one("mark_paid")); + let req = req.json(&body); + let res = self.send_request(req).await?; + Ok(res) + } + + /// This endpoint allows an invoice's status to be set the void status. + /// This can only be done to invoices that are in the issued status. + /// If the associated invoice has used the customer balance to change the amount due, the + /// customer balance operation will be reverted. For example, if the invoice used 10 of + /// $customer balance, that amount will be added back to the customer balance upon voiding. + pub async fn void_invoice<'a>(&self, id: &str) -> Result { + let req = self.build_request(Method::POST, INVOICES.chain_one(id).chain_one("void")); let res = self.send_request(req).await?; Ok(res) } // TODO: get upcoming invoice. - // TODO: void invoice. } diff --git a/src/client/subscriptions.rs b/src/client/subscriptions.rs index d7b47fe..c5ea679 100644 --- a/src/client/subscriptions.rs +++ b/src/client/subscriptions.rs @@ -83,6 +83,60 @@ pub struct CreateSubscriptionRequest<'a> { pub idempotency_key: Option<&'a str>, } +/// https://docs.withorb.com/reference/schedule-plan-change +/// This endpoint can be used to change the plan on an existing subscription. +/// The body parameter change_option determines the timing of the plan change. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)] +pub struct SchedulePlanChangeRequestBody<'a> { + /// The plan that the customer should be switched to. + /// + /// The plan determines the pricing and the cadence of the subscription. + #[serde(flatten)] + pub plan_id: PlanId<'a>, + /// The date that the plan change should take effect. + /// This parameter can only be passed if the change_option is requested_date. + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(with = "time::serde::rfc3339::option")] + pub change_date: Option, + /// Determines the timing of a subscription plan change. + pub change_option: SubscriptionPlanChangeOption, + /// When this subscription's accrued usage reaches this threshold, an invoice will be issued + /// for the subscription. If not specified, invoices will only be issued at the end of the + /// billing period. + #[serde(skip_serializing_if = "Option::is_none")] + pub invoicing_threshold: Option<&'a str>, + /// The phase of the plan to start with + #[serde(skip_serializing_if = "Option::is_none")] + pub initial_phase_order: Option, + /// An idempotency key can ensure that if the same request comes in + /// multiple times in a 48-hour period, only one makes changes. + // NOTE: this is passed in a request header, not the body + #[serde(skip_serializing)] + pub idempotency_key: Option<&'a str>, +} + +/// The body parameter change_option determines the timing of a subscription plan change. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize_enum_str)] +#[serde(rename_all = "snake_case")] +pub enum SubscriptionPlanChangeOption { + /// requested_date: changes the plan on the requested date (change_date). + /// If no timezone is provided, the customer's timezone is used. + /// The change_date body parameter is required if this option is chosen. + RequestedDate, + /// end_of_subscription_term: changes the plan at the end of the existing plan's term. + /// Issuing this plan change request for a monthly subscription will keep the existing + /// plan active until the start of the subsequent month, and potentially issue an invoice + /// for any usage charges incurred in the intervening period. + /// Issuing this plan change request for a yearly subscription will keep the existing plan + /// active for the full year. + EndOfSubscriptionTerm, + /// immediate: changes the plan immediately. Subscriptions that have their plan changed with + /// this option will be invoiced immediately. This invoice will include any usage fees incurred + /// in the billing period up to the change, along with any prorated recurring fees for the + /// billing period, if applicable. + Immediate, +} + #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)] pub struct SubscriptionExternalMarketplaceRequest<'a> { /// The kind of the external marketplace. @@ -137,7 +191,8 @@ pub struct Subscription { /// Determines the default memo on this subscription's invoices. /// /// If `None`, the value is determined by the plan configuration. - pub default_invoice_memo: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub default_invoice_memo: Option, /// The time at which the subscription was created. #[serde(with = "time::serde::rfc3339")] pub created_at: OffsetDateTime, @@ -285,5 +340,27 @@ impl Client { Ok(res) } + /// Changes the associated plan for a subscription. + /// https://docs.withorb.com/reference/schedule-plan-change + pub async fn schedule_plan_change( + &self, + id: &str, + body: &SchedulePlanChangeRequestBody<'_>, + ) -> Result { + let mut req = self.build_request( + Method::POST, + SUBSCRIPTIONS_PATH + .chain_one(id) + .chain_one("schedule_plan_change"), + ); + if let Some(key) = body.idempotency_key { + req = req.header("Idempotency-Key", key); + } + let req = req.json(body); + + let res = self.send_request(req).await?; + Ok(res) + } + // TODO: cancel and unschedule subscriptions. } diff --git a/src/lib.rs b/src/lib.rs index 73f0674..70b66e7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -44,14 +44,15 @@ mod util; pub use client::customers::{ AddIncrementCreditLedgerEntryRequestParams, AddVoidCreditLedgerEntryRequestParams, Address, - AddressRequest, CostViewMode, CreateCustomerRequest, CreditLedgerInvoiceSettingsRequestParams, - Customer, CustomerCostBucket, CustomerCostItem, CustomerCostParams, CustomerCostPriceBlock, + AddressRequest, CostViewMode, CreateCustomerRequest, CreateTopUpRequest, CreditBlockStatus, + CreditLedgerInvoiceSettingsRequestParams, Customer, CustomerCostBucket, CustomerCostItem, + CustomerCostParams, CustomerCostParamsFilter, CustomerCostPriceBlock, CustomerCostPriceBlockMatrixPrice, CustomerCostPriceBlockMatrixPriceConfig, CustomerCostPriceBlockMatrixPriceValue, CustomerCostPriceBlockPrice, CustomerCostPriceBlockPriceGroup, CustomerCostPriceBlockUnitPrice, CustomerCostPriceBlockUnitPriceConfig, CustomerCreditBlock, CustomerId, - CustomerPaymentProviderRequest, LedgerEntry, LedgerEntryRequest, PaymentProvider, - UpdateCustomerRequest, VoidReason, + CustomerPaymentProviderRequest, LedgerEntry, LedgerEntryRequest, ListTopUpsResponse, + PaymentProvider, TopUp, UpdateCustomerRequest, VoidReason, }; pub use client::events::{ AmendEventRequest, Event, EventPropertyValue, EventSearchParams, IngestEventDebugResponse, @@ -59,11 +60,13 @@ pub use client::events::{ }; pub use client::invoices::{ Invoice, InvoiceCustomer, InvoiceListParams, InvoiceStatusFilter, InvoiceSubscription, + MarkInvoiceAsPaidBody, }; pub use client::marketplaces::ExternalMarketplace; pub use client::plans::{Plan, PlanId}; pub use client::subscriptions::{ - CreateSubscriptionRequest, Subscription, SubscriptionListParams, SubscriptionStatus, + CreateSubscriptionRequest, SchedulePlanChangeRequestBody, Subscription, SubscriptionListParams, + SubscriptionPlanChangeOption, SubscriptionStatus, }; pub use client::taxes::{TaxId, TaxIdRequest, TaxIdType}; pub use client::Client;