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

Add support for cancelling subscriptions #46

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ Versioning].

## [Unreleased] <!-- #release:date -->

* Add support for cancelling subscriptions.

## [0.11.0] - 2024-03-29

* Add `portal_url` to `Customer`.
Expand Down
46 changes: 45 additions & 1 deletion src/client/subscriptions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,30 @@ impl<'a> SubscriptionListParams<'a> {
}
}

/// Determines the timing of subscription cancellation
#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize_enum_str, Serialize_enum_str)]
#[serde(rename_all = "snake_case")]
pub enum CancelOption {
/// Stops the subscription from auto-renewing at the end of the current term.
EndOfSubscriptionTerm,
/// Ends the subscription immediately.
Immediate,
/// Ends the subscription on a specified date.
RequestedDate,
}

/// Parameters for cancelling a subscription.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
pub struct CancelSubscriptionParams {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For things that map to a POST body, we prefer to use "request"

Suggested change
pub struct CancelSubscriptionParams {
pub struct CancelSubscriptionRequest {

/// Determines the timing of subscription cancellation.
pub cancel_option: CancelOption,
/// The date that the cancellation should take effect.
/// This parameter can only be used if the cancel_option is RequestedDate.
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(with = "time::serde::rfc3339::option")]
pub cancellation_date: Option<OffsetDateTime>,
}

impl Client {
/// Lists subscriptions as configured by `params`.
///
Expand Down Expand Up @@ -285,5 +309,25 @@ impl Client {
Ok(res)
}

// TODO: cancel and unschedule subscriptions.
/// Cancels an existing subscription.
///
/// This endpoint can be used to cancel an existing subscription. It returns the serialized
/// subscription object with an end_date parameter that signifies when the subscription will
/// transition to an ended state.
pub async fn cancel_subscription(
&self,
subscription_id: &str,
params: &CancelSubscriptionParams,
) -> Result<Subscription, Error> {
let req = self.build_request(
Method::POST,
SUBSCRIPTIONS_PATH
.chain_one(subscription_id)
.chain_one("cancel"),
);

let req = req.json(&params);
let res = self.send_request(req).await?;
Ok(res)
}
}
3 changes: 2 additions & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,8 @@ pub use client::invoices::{
pub use client::marketplaces::ExternalMarketplace;
pub use client::plans::{Plan, PlanId};
pub use client::subscriptions::{
CreateSubscriptionRequest, Subscription, SubscriptionListParams, SubscriptionStatus,
CancelOption, CancelSubscriptionParams, CreateSubscriptionRequest, Subscription,
SubscriptionListParams, SubscriptionStatus,
};
pub use client::taxes::{TaxId, TaxIdRequest, TaxIdType};
pub use client::Client;
Expand Down
78 changes: 70 additions & 8 deletions tests/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,13 @@ use tracing::info;

use orb_billing::{
AddIncrementCreditLedgerEntryRequestParams, AddVoidCreditLedgerEntryRequestParams, Address,
AddressRequest, AmendEventRequest, Client, ClientConfig, CostViewMode, CreateCustomerRequest,
CreateSubscriptionRequest, Customer, CustomerCostParams, CustomerCostPriceBlockPrice,
CustomerId, CustomerPaymentProviderRequest, Error, Event, EventPropertyValue,
EventSearchParams, IngestEventRequest, IngestionMode, InvoiceListParams, LedgerEntry,
LedgerEntryRequest, ListParams, PaymentProvider, SubscriptionListParams, TaxId, TaxIdRequest,
UpdateCustomerRequest, VoidReason,
AddressRequest, AmendEventRequest, CancelOption, CancelSubscriptionParams, Client,
ClientConfig, CostViewMode, CreateCustomerRequest, CreateSubscriptionRequest, Customer,
CustomerCostParams, CustomerCostPriceBlockPrice, CustomerId, CustomerPaymentProviderRequest,
Error, Event, EventPropertyValue, EventSearchParams, IngestEventRequest, IngestionMode,
InvoiceListParams, LedgerEntry, LedgerEntryRequest, ListParams, PaymentProvider,
SubscriptionListParams, SubscriptionStatus, TaxId, TaxIdRequest, UpdateCustomerRequest,
VoidReason,
};

/// The API key to authenticate with.
Expand Down Expand Up @@ -186,7 +187,7 @@ async fn test_customers() {
.try_collect()
.await
.unwrap();
assert_eq!(balance.get(0).unwrap().balance, inc_res.ledger.amount);
assert_eq!(balance.first().unwrap().balance, inc_res.ledger.amount);
let ledger_res = client
.create_ledger_entry(
&customer.id,
Expand Down Expand Up @@ -541,7 +542,7 @@ async fn test_events() {
.try_collect()
.await
.unwrap();
if events.get(0).map(|e| e.event_name.clone()) != Some("new test".into()) {
if events.first().map(|e| e.event_name.clone()) != Some("new test".into()) {
info!(" events list not updated after {iteration} attempts.");
if iteration < MAX_LIST_RETRIES {
continue;
Expand Down Expand Up @@ -661,6 +662,67 @@ async fn test_subscriptions() {
.await
.unwrap();
assert_eq!(fetched_subscriptions, &[subscriptions.remove(0)]);

// Test immediate cancellation
let subscription_to_cancel_immediately = subscriptions.pop().unwrap();
let immediate_cancel_params = CancelSubscriptionParams {
cancel_option: CancelOption::Immediate,
cancellation_date: None,
};
let immediately_cancelled_subscription = client
.cancel_subscription(
&subscription_to_cancel_immediately.id,
&immediate_cancel_params,
)
.await
.unwrap();

assert_eq!(
immediately_cancelled_subscription.id,
subscription_to_cancel_immediately.id
);
assert!(immediately_cancelled_subscription.end_date.is_some());
// The Orb API does not immediately update the status of a cancelled subscription.
// But the end date is set to now when cancelled immediately.
// assert_eq!(immediately_cancelled_subscription.status, Some(SubscriptionStatus::Ended));
Comment on lines +685 to +687
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, there are some frustrating latencies contained in the API. Depending on what you've seen, we'd be open to a sleep (as we do in test_events()). If it's longer than several seconds, I think it's fine to leave this as-is.


// Test cancellation with a requested date
let subscription_to_cancel_with_date = subscriptions.pop().unwrap();
let requested_date = OffsetDateTime::now_utc() + Duration::from_secs(60 * 60 * 24 * 7);
let date_cancel_params = CancelSubscriptionParams {
cancel_option: CancelOption::RequestedDate,
cancellation_date: Some(requested_date),
};
let date_cancelled_subscription = client
.cancel_subscription(&subscription_to_cancel_with_date.id, &date_cancel_params)
.await
.unwrap();

assert_eq!(
date_cancelled_subscription.id,
subscription_to_cancel_with_date.id
);
assert!(date_cancelled_subscription.end_date.is_some());
let end_date = date_cancelled_subscription.end_date.unwrap();
assert!(
end_date - requested_date < Duration::from_secs(60),
"End date {:?} should be within 1 minute of requested date {:?}",
end_date,
requested_date
);
assert_eq!(
date_cancelled_subscription.status,
Some(SubscriptionStatus::Active)
);

// Test that cancelling an already cancelled subscription returns an error
let result = client
.cancel_subscription(
&subscription_to_cancel_immediately.id,
&immediate_cancel_params,
)
.await;
assert_error_with_status_code(result, StatusCode::BAD_REQUEST);
}

#[test(tokio::test)]
Expand Down
Loading