diff --git a/docs/design-principles/0180-billing-integration.md b/docs/design-principles/0180-billing-integration.md
index c66127ba..4bb5b96a 100644
--- a/docs/design-principles/0180-billing-integration.md
+++ b/docs/design-principles/0180-billing-integration.md
@@ -76,7 +76,7 @@ There is much that will change over time with both active subscriptions and the
Instead of building our own system, SaaStack has been designed to be fully two-way integrated with established 3rd party billing providers (e.g., [Chargebee](https://www.chargebee.com), [Maxio](https://www.chargify.com/), or [Stripe Billing](https://www.stripe.com/billing)).
-All these providers offer APIs for integration as well as management portals and tools for handling subscriptions, plans, pricing, trials, discounts, and coupons. The API interface (and webhooks) provided by the BMS becomes the user interface of your product that your customers can self-serve with. The management portal the BMS provides becomes an administrative tool your business (product, support & success, etc) can use to manage customers, billing and pricing changes long term.
+All these providers offer APIs for integration as well as management portals and tools for handling subscriptions, plans, pricing, trials, discounts, and coupons. The API interface (and webhooks) provided by the BMS becomes the user interface of your product that your customers can self-serve with. The management portal the BMS provides becomes an administrative tool your business (product, support & success, etc) can use to manage customers, billing, and pricing changes long term.
This bidirectional approach introduces a need for seamless synchronization between your SaaS product and third-party services, as changes can occur in both systems independently (due to different actors). Therefore, the product's backend API will be needed (via webhooks) to modify and synchronize subscriptions from the BMS and ensure consistency between the two systems. Eventual consistency is completely tolerable in this scenario.
@@ -137,11 +137,11 @@ The default set of tiers (`SubscriptionTier`), modeled in SaaStack, has been des
The progression through these tiers represents a variant of a very common "Freemium" model, where:
1. The end-new user starts on the `Standard` tier, which is initially a "free" tier (with or without a Trial period)
-2. If Trials are supported by the BMS, the end-user gets to try out `Standard` tier features for a period of time before the trial ends, at which point the subscription will require payment of some kind (a valid `PaymentMethod`). If payment is received (in time), the end-user keeps `Standard` tier access from that point in time (and the Trial ends). If no payment is received (in time), the end-user is automatically downgraded to the `Unsubscribed` tier, which has permanent "free" access to a limited set of basic features.
+2. If Trials are supported by the BMS, the end-user gets to try out `Standard` tier features for a period of time before the trial ends, at which point the subscription will require payment of some kind (a valid payment method). If payment is received (in time), the end-user keeps `Standard` tier access from that point in time (and the Trial ends). If no payment is received (in time), the end-user is automatically downgraded to the `Unsubscribed` tier, which has permanent "free" access to a limited set of basic features.
3. At any time during the trial (or outside a trial period), at any tier, the end-user can upgrade to any other tier. They can also cancel their subscription and will be automatically reverted to the `Unsubscribed` tier.
-4. Lastly, in some rare cases, if a subscription in the BMS system itself is "deleted/destroyed" (by a business "administrator" of some kind), the subscription will be reverted to the `Unsubscribed` tier again, as a fallback.
+4. Lastly, in some rare cases, if a subscription in the BMS system itself is "deleted/destroyed" (by a business "administrator" of some kind), the subscription will be reverted to the `Unsubscribed` tier again as a fallback.
-Bottom line, is that this is flexible strategy to get started for most SaaS businesses, that will, no doubt adapt this default workflow moving forward.
+The bottom line is that this is a flexible strategy to get started for most SaaS businesses that will, no doubt, adapt this default workflow moving forward.
> You are free to change these default tiers and add or remove your own. The details that drive the restrictions will come from the plan configuration in the BMS and need to be synchronized in the code, too.
@@ -216,7 +216,7 @@ Through this API, end-users (members of an organization, by default) can perform
The API itself, will interact with the `IBillingProvider` to achieve those things. In that way, it delegates some of those [transactional] commands directly with the BMS. But at the same time, it maintains a cache of relevant metadata (about the subscription and plan from the BMS) in the API, so that the API does not have to contact the BMS for all non-transactional activities.
-Lastly, in order to maintain eventual consistency between data changing in the BMS, which can change quite independently in the BMS (from other actors), the `Subscription` subdomain needs to handle webhook events originating from the BMS, or use polling techniques to obtain those changes.
+Lastly, in order to maintain eventual consistency between data changing in the BMS, which can change quite independently in the BMS (from other actors), the `Subscription` subdomain needs to handle webhook events originating from the BMS or use polling techniques to obtain those changes.
> The webhooks will be different for different BMSs.
@@ -267,7 +267,7 @@ In any SaaS product, it is common to restrict access to certain features and fun
Some plans define access to whole feature sets, while others put limits and quotas on the usage of those features.
-> Some features of a SaaS product may not be "tenanted" and will require access to be granted to individual users, rather than to specific members of organizations.
+> Some features of a SaaS product may not be "tenanted" and will require access to be granted to individual users rather than to specific members of organizations.
Since a plan can be changed at any time during the use of the SaaS product, and since the features of the product cannot be deployed to each user on-demand instantly, access to features is required to be *dynamically* controlled by the software itself, as it is being used by specific end-users.
@@ -360,11 +360,11 @@ As no credit card would be provided, when a `Subscription` is first created, the
When a new `Subscription` is created (for an `Organization`), it automatically assigns the "creator" of the organization to the "buyer" of the subscription.
-As a "buyer" of the subscription, they have full payment authority, and they are responsible for paying any charges for the subscription (e.g. setup fees and/or monthly/annual subscription fees). Charging will happen on a frequency defined by the subscription plan and any other terms of service. But there will not be any `PaymentMethod` at this point in time to charge.
+As a "buyer" of the subscription, they have full payment authority, and they are responsible for paying any charges for the subscription (e.g. setup fees and/or monthly/annual subscription fees). Charging will happen on a frequency defined by the subscription plan and any other terms of service. But there will not be any payment method at this point in time to charge.
In order to be charged, the "buyer" will have needed to register a valid `PaymentMethod` for the subscription, that can be used to charge when that time comes.
-> By default, the `SimpleBillingProvider` will never require any charges, and therefore never requires a valid `PaymentMethod`
+> By default, the `SimpleBillingProvider` will never require any charges and therefore, never requires a valid `PaymentMethod`
Once there are charges, there are generally restrictions on feature access associated with the "tier" of the subscription (e.g., "Basic" versus "Premium").
@@ -454,7 +454,7 @@ By default, there are a number of constraints and rules placed on `Subscriptions
#### Role Access
-These are the roles and rules with respect to billing, and organizations.
+These are the roles and rules with respect to billing and organizations.
| End User | Is Creator | Is Current Buyer | Roles |
|-----------------------------------------|------------|------------------------------|----------------------------|
@@ -463,7 +463,7 @@ These are the roles and rules with respect to billing, and organizations.
| An (Organization) Owner | never | never | `TenantRoles.Owner` |
| An (Organization) Member | never | never | `TenantRoles.Member` |
-> Note: the roles `TenantRoles.BillingAdmin` and `TenantRoles.Owner` are hierarchical and supersets of other roles (like `TenantRoles.Member`).
+> Note: the roles `TenantRoles.BillingAdmin` and `TenantRoles.Owner` are hierarchical and are supersets of other roles (like `TenantRoles.Member`).
Rules:
@@ -575,6 +575,6 @@ Either defining limits or quotas for specific kinds of plans.
### Grandfathering
-Subscription pricing for SaaS businesses can change frequently, and existing subscriptions are bound by legal agreements. "Grandfathering" allows past purchasers to retain their original terms, or be moved to equivalent new plans in the new pricing model.
+Subscription pricing for SaaS businesses can change frequently, and existing subscriptions are bound by legal agreements. "Grandfathering" allows past purchasers to retain their original terms or be moved to equivalent new plans in the new pricing model.
While third-party BMSs (e.g., [Chargebee](https://www.chargebee.com), [Maxio](https://www.chargify.com/), or [Stripe Billing](https://www.stripe.com/billing), etc.) provide support for grandfathering, supporting it fully may require some additional work in each `IBillingProvider`, depending on the extent of it.
\ No newline at end of file
diff --git a/docs/how-to-guides/900-migrate-billing-provider.md b/docs/how-to-guides/900-migrate-billing-provider.md
index e6931ba6..bc15edce 100644
--- a/docs/how-to-guides/900-migrate-billing-provider.md
+++ b/docs/how-to-guides/900-migrate-billing-provider.md
@@ -11,7 +11,7 @@ There are two pieces of this mechanism:
1. An implementation of an `IBillingProvider` specific to the BMS.
2. Webhooks, or custom syncing mechanisms to ensure that changes in the BMS reach this product.
-> We highly recommend using Webhooks notifications where possible; otherwise, you really must poll the BMS on a frequent basis, and risk being rate-limited.
+> We highly recommend using Webhooks notifications where possible; otherwise, you must poll the BMS frequently and you risk being rate-limited.
## Where to start?
@@ -41,7 +41,7 @@ Other BMS will have slightly different conceptual models, and you will need to u
You will also need to configure the basic rules and other policies in the BMS first, before you start configuring your customer data.
-For example, configure API Keys, Webhooks etc.
+For example, configure API Keys, Webhooks, etc.
The last thing will be to explore whether the BMS supports a "sandbox" environment for you to play around with and test your migration. You don't want to be adding test data to your production customer data.
@@ -55,7 +55,7 @@ Use the API endpoint `GET /subscriptions/export` to view the data available for
This data represents all the subscriptions created in the product so far.
-This is the data you will need to import into your chosen BMS, during the migration.
+This is the data you will need to import into your chosen BMS during the migration.
> Note: some of the values are simply encoded JSON values
@@ -91,7 +91,7 @@ This is the data you will need to import into your chosen BMS, during the migrat
}
```
-### Build Your Scripts
+### Build Your Migration Scripts
You will likely need to build some scripts that translate the raw data above and automate the creation of various related data structures in the new BMS.
@@ -99,7 +99,7 @@ For example, in Chargebee:
1. You would create a Chargebee Customer record using the data in the `buyer` property. You would save the `buyer.id` in the metadata of the Chargebee Customer record.
2. You would create a Chargebee Subscription for the Chargebee Customer. You would also save the `id` and `owningEntityId` as metadata in the Chargebee Subscription.
-3. You would define some Chargebee Plans, and assign one of those plans to the Chargebee subscription.
+3. You would define some Chargebee Plans and assign one of those plans to the Chargebee Subscription.
Next, during the migration, once you have automated the creation of the BMS records, you will also need a collection of metadata of those BMS records back into the data of the `IBillingProvider` for when it is being used.
@@ -131,7 +131,7 @@ In the product, by default, we have defined the following tiers (see: `Subscript
* Professional
* Enterprise
-You are free to rename, add, or remove these tiers (in the code) to whatever you would like to support in your future pricing plans in your new BMS. Essentially, we have 3 paid tiers, where `Standard` may have a trial, and is generally the default plan for new users.
+You are free to rename, add, or remove these tiers (in the code) to whatever you would like to support in your future pricing plans in your new BMS. Essentially, we have 3 paid tiers, where `Standard` may have a trial and is generally the default plan for new users.
> Remember, if you modify these tiers, you will also need to modify the mapping between these tiers and the feature levels you will be supporting in your pricing plans. see the `EndUserRoot` for details.
@@ -146,7 +146,7 @@ In your BMS, we recommend defining at least the following plans:
You will need to define all the parameters for each of these new plans, including pricing, limits, frequency of billing, etc.
-### Configure the BillingProvider
+### Configure the Billing Provider
Your newly chosen BMS will require a built and tested implementation of the `IBillingProvider` to work with it.
@@ -160,7 +160,7 @@ To swap out the existing `IBillingProvider` (e.g. `SimpleBillingProvider`) with
You will also need to make sure that you provide all the necessary configuration settings for your new `IBillingProvider` in the relevant `appsettings.json` files for the host project where the `Subscriptions` subdomain is deployed.
-You might also consider updating your web/mobile apps to support the self-serve of capturing credit cards (payment methods), and support self-serve for changing plans. However, the built-in pricing page in the `WebsiteHost` should be already updated with your new plans.
+You might also consider updating your web/mobile apps to support the self-serve of capturing credit cards (payment methods), and support self-serve for changing plans. However, the built-in pricing page in the `WebsiteHost` should already be updated with your new plans.
> None of the BMS-specific UX is built in when using the `SimpleBillingProvider` as this provider neither allows you to select from a list of plans nor captures payment methods.
diff --git a/src/ApiHost1/appsettings.json b/src/ApiHost1/appsettings.json
index f93fcb32..69e41668 100644
--- a/src/ApiHost1/appsettings.json
+++ b/src/ApiHost1/appsettings.json
@@ -35,6 +35,22 @@
"AesSecret": "V7z5SZnhHRa7z68adsvazQjeIbSiWWcR+4KuAUikhe0=::u4ErEVotb170bM8qKWyT8A=="
}
},
+ "Chargebee": {
+ "BaseUrl": "https://localhost:5656/chargebee/",
+ "ApiKey": "anapikey",
+ "SiteName": "asitename",
+ "ProductFamilyId": "afamilyid",
+ "Plans": {
+ "StartingPlanId": "apaidtrial",
+ "Tier1PlanIds": "apaidtrial",
+ "Tier2PlanIds": "apaid2",
+ "Tier3PlanIds": "apaid3"
+ },
+ "Webhook": {
+ "Username": "ausername",
+ "Password": "apassword"
+ }
+ },
"Flagsmith": {
"BaseUrl": "https://localhost:5656/flagsmith/",
"EnvironmentKey": ""
diff --git a/src/Application.Interfaces/Audits.Designer.cs b/src/Application.Interfaces/Audits.Designer.cs
index 4cfbd499..3b21a399 100644
--- a/src/Application.Interfaces/Audits.Designer.cs
+++ b/src/Application.Interfaces/Audits.Designer.cs
@@ -95,6 +95,15 @@ public static string AuthTokensApplication_Refresh_Succeeded {
}
}
+ ///
+ /// Looks up a localized string similar to Chargebee.Authentication.Failed.
+ ///
+ public static string ChargebeeApi_WebhookAuthentication_Failed {
+ get {
+ return ResourceManager.GetString("ChargebeeApi_WebhookAuthentication_Failed", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to CSRFProtection.Failed.
///
diff --git a/src/Application.Interfaces/Audits.resx b/src/Application.Interfaces/Audits.resx
index 4207006c..74539117 100644
--- a/src/Application.Interfaces/Audits.resx
+++ b/src/Application.Interfaces/Audits.resx
@@ -114,4 +114,7 @@
Authentication.Any.Refreshed.Failed.AccountSuspended
+
+ Chargebee.Authentication.Failed
+
\ No newline at end of file
diff --git a/src/Application.Resources.Shared/Chargebee.cs b/src/Application.Resources.Shared/Chargebee.cs
new file mode 100644
index 00000000..2d20d330
--- /dev/null
+++ b/src/Application.Resources.Shared/Chargebee.cs
@@ -0,0 +1,168 @@
+using System.Runtime.Serialization;
+using System.Text.Json.Serialization;
+
+namespace Application.Resources.Shared;
+
+public static class ChargebeeConstants
+{
+ public const string ProviderName = "chargebee_billing";
+ public const string AuditSourceName = ProviderName;
+
+
+ public static class MetadataProperties
+ {
+ public const string BillingAmount = "BillingAmount";
+ public const string BillingPeriodUnit = "BillingPeriodUnit";
+ public const string BillingPeriodValue = "BillingPeriodValue";
+ public const string CanceledAt = "CanceledAt";
+ public const string CurrencyCode = "CurrencyCode";
+ public const string CustomerId = "CustomerId";
+ public const string NextBillingAt = "NextBillingAt";
+ public const string PaymentMethodStatus = "PaymentMethodStatus";
+ public const string PaymentMethodType = "PaymentMethodType";
+ public const string PlanId = "PlanId";
+ public const string SubscriptionDeleted = "SubscriptionDeleted";
+ public const string SubscriptionId = "SubscriptionId";
+ public const string SubscriptionStatus = "SubscriptionStatus";
+ public const string TrialEnd = "TrialEnd";
+ }
+}
+
+public enum ChargebeeEventType
+{
+ Unknown = 0,
+ [EnumMember(Value = "coupon_created")] CouponCreated,
+ [EnumMember(Value = "coupon_updated")] CouponUpdated,
+ [EnumMember(Value = "credit_note_created")]
+ CreditNoteCreated,
+ [EnumMember(Value = "credit_note_deleted")]
+ CreditNoteDeleted,
+ [EnumMember(Value = "credit_note_updated")]
+ CreditNoteUpdated,
+ [EnumMember(Value = "customer_changed")]
+ CustomerChanged,
+ [EnumMember(Value = "customer_created")]
+ CustomerCreated,
+ [EnumMember(Value = "customer_deleted")]
+ CustomerDeleted,
+ [EnumMember(Value = "invoice_deleted")]
+ InvoiceDeleted,
+ [EnumMember(Value = "invoice_generated")]
+ InvoiceGenerated,
+ [EnumMember(Value = "invoice_updated")]
+ InvoiceUpdated,
+ [EnumMember(Value = "item_updated")] ItemUpdated,
+ [EnumMember(Value = "payment_failed")] PaymentFailed,
+ [EnumMember(Value = "payment_initiated")]
+ PaymentInitiated,
+ [EnumMember(Value = "payment_refunded")]
+ PaymentRefunded,
+ [EnumMember(Value = "payment_source_added")]
+ PaymentSourceAdded,
+ [EnumMember(Value = "payment_source_deleted")]
+ PaymentSourceDeleted,
+ [EnumMember(Value = "payment_source_expired")]
+ PaymentSourceExpired,
+ [EnumMember(Value = "payment_source_expiring")]
+ PaymentSourceExpiring,
+ [EnumMember(Value = "payment_source_updated")]
+ PaymentSourceUpdated,
+ [EnumMember(Value = "payment_succeeded")]
+ PaymentSucceeded,
+ [EnumMember(Value = "plan_updated")] PlanUpdated,
+ [EnumMember(Value = "subscription_activated")]
+ SubscriptionActivated,
+ [EnumMember(Value = "subscription_cancellation_reminder")]
+ SubscriptionCancellationReminder,
+ [EnumMember(Value = "subscription_cancellation_scheduled")]
+ SubscriptionCancellationScheduled,
+ [EnumMember(Value = "subscription_cancelled")]
+ SubscriptionCancelled,
+ [EnumMember(Value = "subscription_changed")]
+ SubscriptionChanged,
+ [EnumMember(Value = "subscription_changes_scheduled")]
+ SubscriptionChangesScheduled,
+ [EnumMember(Value = "subscription_created")]
+ SubscriptionCreated,
+ [EnumMember(Value = "subscription_deleted")]
+ SubscriptionDeleted,
+ [EnumMember(Value = "subscription_reactivated")]
+ SubscriptionReactivated,
+ [EnumMember(Value = "subscription_renewal_reminder")]
+ SubscriptionRenewalReminder,
+ [EnumMember(Value = "subscription_renewed")]
+ SubscriptionRenewed,
+ [EnumMember(Value = "subscription_scheduled_cancellation_removed")]
+ SubscriptionScheduledCancellationRemoved,
+ [EnumMember(Value = "subscription_scheduled_changes_removed")]
+ SubscriptionScheduledChangesRemoved,
+ [EnumMember(Value = "subscription_trial_extended")]
+ SubscriptionTrialExtended
+}
+
+public class ChargebeeEventContent
+{
+ public ChargebeeEventCustomer? Customer { get; set; }
+
+ public ChargebeeEventSubscription? Subscription { get; set; }
+}
+
+public class ChargebeeEventCustomer
+{
+ public string? Id { get; set; }
+
+ [JsonPropertyName("payment_method")] public ChargebeePaymentMethod? PaymentMethod { get; set; }
+}
+
+public class ChargebeeEventSubscription
+{
+ [JsonPropertyName("billing_period")] public int? BillingPeriod { get; set; }
+
+ [JsonPropertyName("billing_period_unit")]
+ public string? BillingPeriodUnit { get; set; }
+
+ [JsonPropertyName("cancelled_at")] public long? CancelledAt { get; set; }
+
+ [JsonPropertyName("currency_code")] public string? CurrencyCode { get; set; }
+
+ [JsonPropertyName("customer_id")] public string? CustomerId { get; set; }
+
+ [JsonPropertyName("deleted")] public bool? Deleted { get; set; }
+
+ public string? Id { get; set; }
+
+ [JsonPropertyName("next_billing_at")] public long? NextBillingAt { get; set; }
+
+ public string? Status { get; set; }
+
+ [JsonPropertyName("subscription_items")]
+ public List SubscriptionItems { get; set; } = new();
+
+ [JsonPropertyName("trial_end")] public long? TrialEnd { get; set; }
+
+ [JsonPropertyName("trial_start")] public long? TrialStart { get; set; }
+}
+
+public class ChargebeeEventSubscriptionItem
+{
+ [JsonPropertyName("amount")] public decimal? Amount { get; set; } // total amount in cents
+
+ [JsonPropertyName("item_price_id")] public string? ItemPriceId { get; set; } // plan id
+
+ [JsonPropertyName("item_type")] public string? ItemType { get; set; } // values: plan, addon or charge
+
+ [JsonPropertyName("quantity")] public int? Quantity { get; set; }
+
+ [JsonPropertyName("unit_price")] public decimal? UnitPrice { get; set; } // price in cents
+}
+
+public class ChargebeePaymentMethod
+{
+ public string? Id { get; set; }
+
+ [JsonPropertyName("status")]
+ public string? Status { get; set; } //values: valid, expiring, expired, invalid, pending_verification
+
+ [JsonPropertyName("type")]
+ public string? Type { get; set; } // values: card, paypal_express_checkout, amazon_payments, direct_debit, etc, etc
+}
\ No newline at end of file
diff --git a/src/Infrastructure.Shared.IntegrationTests/ApplicationServices/External/ChargebeeHttpServiceClientSpec.cs b/src/Infrastructure.Shared.IntegrationTests/ApplicationServices/External/ChargebeeHttpServiceClientSpec.cs
new file mode 100644
index 00000000..23993134
--- /dev/null
+++ b/src/Infrastructure.Shared.IntegrationTests/ApplicationServices/External/ChargebeeHttpServiceClientSpec.cs
@@ -0,0 +1,858 @@
+using Application.Interfaces;
+using Application.Resources.Shared;
+using Application.Services.Shared;
+using ChargeBee.Models;
+using Common;
+using Common.Configuration;
+using Common.Extensions;
+using Common.Recording;
+using Domain.Shared.Subscriptions;
+using FluentAssertions;
+using Infrastructure.Shared.ApplicationServices.External;
+using IntegrationTesting.WebApi.Common;
+using JetBrains.Annotations;
+using UnitTesting.Common;
+using UnitTesting.Common.Validation;
+using Xunit;
+using Constants = Infrastructure.Shared.ApplicationServices.External.ChargebeeStateInterpreter.Constants;
+using Subscription = ChargeBee.Models.Subscription;
+
+namespace Infrastructure.Shared.IntegrationTests.ApplicationServices.External;
+
+public abstract class ChargebeeHttpServiceClientSetupSpec : ExternalApiSpec
+{
+ private const string TestCustomerIdPrefix = "testorganizationid";
+ private const string TestUserIdPrefix = "testuserid";
+ protected static readonly TestPlan SetupPlan = new("SetupFee", 10M, "A setup fee", false, false, false);
+ protected static readonly TestFeature TestFeature1 = new("Feature1", "A feature1");
+ protected static readonly TestPlan[] TestPlans =
+ [
+ new TestPlan("Trial", 50M, "Trial plan", true, false, false),
+ new TestPlan("Paid", 100M, "Paid plan", false, true, false),
+ new TestPlan("PaidWithSetup", 100M, "PaidWithSetup plan", false, true, true)
+ ];
+
+ protected ChargebeeHttpServiceClientSetupSpec(ExternalApiSetup setup) : base(setup, null,
+ _ =>
+ {
+ var settings = setup.GetRequiredService();
+ var serviceClient = new ChargebeeHttpServiceClient(NoOpRecorder.Instance, settings);
+ var productFamilyId = settings.Platform.GetString(Constants.ProductFamilyIdSettingName);
+ SetupTestingSandboxAsync(new TestCaller(), serviceClient, productFamilyId).GetAwaiter().GetResult();
+ })
+ {
+ Caller = new TestCaller();
+ var settings = setup.GetRequiredService();
+ ServiceClient = new ChargebeeHttpServiceClient(NoOpRecorder.Instance, settings);
+ ProductFamilyId = settings.Platform.GetString(Constants.ProductFamilyIdSettingName);
+ }
+
+ protected ICallerContext Caller { get; }
+
+ protected string ProductFamilyId { get; }
+
+ protected ChargebeeHttpServiceClient ServiceClient { get; }
+
+ protected async Task CancelSubscriptionAsync(BillingProvider provider,
+ CancelSubscriptionOptions options)
+ {
+ var result = await ServiceClient.CancelSubscriptionAsync(Caller, options, provider, CancellationToken.None);
+ return BillingProvider.Create(Constants.ProviderName, result.Value)
+ .Value;
+ }
+
+ protected static SubscriptionBuyer CreateBuyer()
+ {
+ var random = Guid.NewGuid().ToString("N").Substring(0, 8);
+ return new SubscriptionBuyer
+ {
+ Address = new ProfileAddress
+ {
+ CountryCode = CountryCodes.Default.ToString()
+ },
+ Subscriber = new Subscriber
+ {
+ EntityId = $"{TestCustomerIdPrefix}{random}",
+ EntityType = nameof(Organization)
+ },
+ EmailAddress = "auser@company.com",
+ Id = TestUserIdPrefix,
+ Name = new PersonName
+ {
+ FirstName = "afirstname",
+ LastName = "alastname"
+ },
+ PhoneNumber = null
+ };
+ }
+
+ protected async Task<(SubscriptionBuyer Buyer, Customer Customer, BillingProvider Provider)> CreateCustomerAsync()
+ {
+#if TESTINGONLY
+ var buyer = CreateBuyer();
+ var customer = (await ServiceClient.CreateCustomerAsync(Caller, buyer, CancellationToken.None)).Value;
+ var provider = BillingProvider
+ .Create(Constants.ProviderName, customer.ToCustomerState())
+ .Value;
+
+ return (buyer, customer, provider);
+#else
+ await Task.CompletedTask;
+ return (null!, null!, null!);
+#endif
+ }
+
+ ///
+ /// Returns a new customer with a valid payment source and subscribes them to the given plan, or initial plan
+ ///
+ protected async Task SubscribeCustomerWithCardAsync(string? planId = null)
+ {
+ var (buyer, customer, _) = await CreateCustomerAsync();
+#if TESTINGONLY
+ (await ServiceClient.CreateCustomerPaymentMethod(Caller, customer.Id, CancellationToken.None))
+ .ThrowOnError();
+#endif
+
+ var options = SubscribeOptions.Immediately;
+#if TESTINGONLY
+ if (planId.HasValue())
+ {
+ options.PlanId = planId;
+ }
+#endif
+
+ var subscribed = await ServiceClient.SubscribeAsync(Caller, buyer,
+ options, CancellationToken.None);
+ return BillingProvider.Create(Constants.ProviderName, subscribed.Value)
+ .Value;
+ }
+
+ protected static string ToBillingAmount(TestPlan plan, CurrencyCodeIso4217? currency = null)
+ {
+ return CurrencyCodes.ToMinorUnit(currency ?? CurrencyCodes.Default, plan.Price).ToString();
+ }
+
+ protected async Task UnsubscribeAsync(BillingProvider provider)
+ {
+ var customerId = provider.State.GetValueOrDefault(ChargebeeConstants.MetadataProperties.CustomerId)!;
+ var subscriptionId = provider.State.GetValueOrDefault(ChargebeeConstants.MetadataProperties.SubscriptionId)!;
+#if TESTINGONLY
+ var result = await ServiceClient.DeleteSubscriptionAsync(Caller, subscriptionId, CancellationToken.None);
+ if (result.IsFailure)
+ {
+ return provider;
+ }
+#endif
+
+ return BillingProvider.Create(Constants.ProviderName, new SubscriptionMetadata
+ {
+ { ChargebeeConstants.MetadataProperties.CustomerId, customerId },
+ { ChargebeeConstants.MetadataProperties.SubscriptionId, subscriptionId },
+ { ChargebeeConstants.MetadataProperties.SubscriptionDeleted, "true" }
+ }).Value;
+ }
+
+ private static async Task SetupTestingSandboxAsync(ICallerContext caller, ChargebeeHttpServiceClient serviceClient,
+ string productFamilyId)
+ {
+#if TESTINGONLY
+ // Cleanup any existing data
+ var subscriptions =
+ (await serviceClient.SearchAllSubscriptionsAsync(caller, new SearchOptions(),
+ CancellationToken.None))
+ .Value;
+ foreach (var subscription in subscriptions.Where(sub => sub.CustomerId.StartsWith(TestCustomerIdPrefix)))
+ {
+ (await serviceClient.DeleteSubscriptionAsync(caller, subscription.Id, CancellationToken.None))
+ .ThrowOnError();
+ (await serviceClient.DeleteCustomerAsync(caller, subscription.CustomerId, CancellationToken.None))
+ .ThrowOnError();
+ }
+
+ // Cleanup any orphaned customers
+ var customers =
+ (await serviceClient.SearchAllCustomersAsync(caller, new SearchOptions(), CancellationToken.None))
+ .Value;
+ foreach (var customer in customers.Where(c => c.Id.StartsWith(TestCustomerIdPrefix)
+ && c.Deleted == false))
+ {
+ // Ignore errors (e.g. customer has already been scheduled for delete)
+ await serviceClient.DeleteCustomerAsync(caller, customer.Id, CancellationToken.None);
+ }
+
+ var plans = (await serviceClient.SearchAllPlansAsync(caller, new SearchOptions(), CancellationToken.None))
+ .Value;
+ foreach (var plan in plans)
+ {
+ var features =
+ (await serviceClient.SearchAllPlanFeaturesAsync(caller, plan.Id, new SearchOptions(),
+ CancellationToken.None)).Value;
+ foreach (var feature in features)
+ {
+ (await serviceClient.RemovePlanFeatureAsync(caller, plan.Id, feature.FeatureId,
+ CancellationToken.None)).ThrowOnError();
+ (await serviceClient.DeleteFeatureAsync(caller, feature.FeatureId, CancellationToken.None))
+ .ThrowOnError();
+ }
+
+ (await serviceClient.DeletePlanAndPricesAsync(caller, plan.Id, CancellationToken.None)).ThrowOnError();
+ }
+
+ var charges =
+ (await serviceClient.SearchAllChargesAsync(caller, new SearchOptions(), CancellationToken.None)).Value;
+ foreach (var charge in charges)
+ {
+ (await serviceClient.DeleteChargeAndPricesAsync(caller, charge.Id, CancellationToken.None))
+ .ThrowOnError();
+ }
+
+ // Create new test data (reactivate archived items if necessary)
+ await serviceClient.CreateProductFamilySafelyAsync(caller, productFamilyId, CancellationToken.None);
+ var feature1 = (await serviceClient.CreateFeatureSafelyAsync(caller, TestFeature1.Name,
+ TestFeature1.Description, CancellationToken.None)).Value;
+ var setupCharge = (await serviceClient.CreateChargeSafelyAsync(caller, productFamilyId, SetupPlan.Name,
+ SetupPlan.Description, CancellationToken.None)).Value;
+ var setupChargePrice = (await serviceClient.CreateOneOffItemPriceAsync(caller, setupCharge.Id,
+ SetupPlan.Description, CurrencyCodes.Default, SetupPlan.Price, CancellationToken.None)).Value;
+ foreach (var testPlan in TestPlans)
+ {
+ var plan = (await serviceClient.CreatePlanSafelyAsync(caller, productFamilyId, testPlan.Name,
+ testPlan.Description, CancellationToken.None)).Value;
+
+ (await serviceClient.CreateMonthlyRecurringItemPriceAsync(caller, plan.Id, testPlan.Description,
+ CurrencyCodes.Default, testPlan.Price, testPlan.HasTrial, CancellationToken.None)).ThrowOnError();
+
+ if (testPlan.HasFeature)
+ {
+ (await serviceClient.AddPlanFeatureAsync(caller, plan.Id, feature1.Id, CancellationToken.None))
+ .ThrowOnError();
+ }
+
+ if (testPlan.HasSetupCharge)
+ {
+ (await serviceClient.AddPlanChargeAsync(caller, plan.Id, setupChargePrice.ItemId,
+ CancellationToken.None)).ThrowOnError();
+ }
+ }
+#endif
+ }
+
+ protected record TestPlan(
+ string Name,
+ decimal Price,
+ string Description,
+ bool HasTrial,
+ bool HasFeature,
+ bool HasSetupCharge)
+ {
+ public string PlanId => $"{Name}-USD-Monthly";
+ }
+
+ protected record TestFeature(
+ string Name,
+ string Description);
+}
+
+///
+/// These tests directly test the adapter against a live instance of Chargebee API.
+/// Note: Some of the tests plans include a mandatory setup fee that requires a PaymentSource to be added by the
+/// customer before they can subscribe to that plan. The setup fee is a one-time charge that is added to the first
+/// invoice.
+/// Note: you will have to set up Chargebee Timezone, and default Currency (in the Configure Page of the Portal)
+///
+[UsedImplicitly]
+public class ChargebeeHttpServiceClientSpec
+{
+ [Trait("Category", "Integration.External")]
+ [Collection("External")]
+ public class GivenNoSubscriptions : ChargebeeHttpServiceClientSetupSpec
+ {
+ public GivenNoSubscriptions(ExternalApiSetup setup) : base(setup)
+ {
+ }
+
+ [Fact]
+ public async Task WhenListAllPricingPlansAsync_ThenReturnsPlans()
+ {
+ var result = await ServiceClient.ListAllPricingPlansAsync(Caller, CancellationToken.None);
+
+ result.Should().BeSuccess();
+ result.Value.Eternally.Should().BeEmpty();
+ result.Value.Annually.Should().BeEmpty();
+ result.Value.Weekly.Should().BeEmpty();
+ result.Value.Daily.Should().BeEmpty();
+ result.Value.Monthly.Count.Should().Be(3);
+ var monthlyPlan1 = result.Value.Monthly[0];
+ monthlyPlan1.Id.Should().Be(TestPlans[0].PlanId);
+ monthlyPlan1.Cost.Should().Be(TestPlans[0].Price);
+ monthlyPlan1.Currency.Should().Be(CurrencyCodes.Default.Code);
+ monthlyPlan1.Description.Should().Be(TestPlans[0].Description);
+ monthlyPlan1.DisplayName.Should().Be(TestPlans[0].Name);
+ monthlyPlan1.FeatureSection.Count.Should().Be(0);
+ monthlyPlan1.IsRecommended.Should().BeFalse();
+ monthlyPlan1.Notes.Should().Be(TestPlans[0].Description);
+ monthlyPlan1.Period.Unit.Should().Be(PeriodFrequencyUnit.Month);
+ monthlyPlan1.Period.Frequency.Should().Be(1);
+ monthlyPlan1.SetupCost.Should().Be(0);
+ monthlyPlan1.Trial!.HasTrial.Should().BeTrue();
+ monthlyPlan1.Trial.Frequency.Should().Be(7);
+ monthlyPlan1.Trial.Unit.Should().Be(PeriodFrequencyUnit.Day);
+
+ var monthlyPlan2 = result.Value.Monthly[1];
+ monthlyPlan2.Id.Should().Be(TestPlans[2].PlanId);
+ monthlyPlan2.Cost.Should().Be(TestPlans[2].Price);
+ monthlyPlan2.Currency.Should().Be(CurrencyCodes.Default.Code);
+ monthlyPlan2.Description.Should().Be(TestPlans[2].Description);
+ monthlyPlan2.DisplayName.Should().Be(TestPlans[2].Name);
+ monthlyPlan2.FeatureSection.Count.Should().Be(1);
+ monthlyPlan2.FeatureSection[0].Description.Should().BeNull();
+ monthlyPlan2.FeatureSection[0].Features.Count.Should().Be(1);
+ monthlyPlan2.FeatureSection[0].Features[0].IsIncluded.Should().BeTrue();
+ monthlyPlan2.FeatureSection[0].Features[0].Description.Should().Be(TestFeature1.Description);
+ monthlyPlan2.IsRecommended.Should().BeFalse();
+ monthlyPlan2.Notes.Should().Be(TestPlans[2].Description);
+ monthlyPlan2.Period.Unit.Should().Be(PeriodFrequencyUnit.Month);
+ monthlyPlan2.Period.Frequency.Should().Be(1);
+ monthlyPlan2.SetupCost.Should().Be(SetupPlan.Price);
+ monthlyPlan2.Trial.Should().BeNull();
+
+ var monthlyPlan3 = result.Value.Monthly[2];
+ monthlyPlan3.Id.Should().Be(TestPlans[1].PlanId);
+ monthlyPlan3.Cost.Should().Be(TestPlans[1].Price);
+ monthlyPlan3.Currency.Should().Be(CurrencyCodes.Default.Code);
+ monthlyPlan3.Description.Should().Be(TestPlans[1].Description);
+ monthlyPlan3.DisplayName.Should().Be(TestPlans[1].Name);
+ monthlyPlan3.FeatureSection.Count.Should().Be(1);
+ monthlyPlan3.FeatureSection[0].Description.Should().BeNull();
+ monthlyPlan3.FeatureSection[0].Features.Count.Should().Be(1);
+ monthlyPlan3.FeatureSection[0].Features[0].IsIncluded.Should().BeTrue();
+ monthlyPlan3.FeatureSection[0].Features[0].Description.Should().Be(TestFeature1.Description);
+ monthlyPlan3.IsRecommended.Should().BeFalse();
+ monthlyPlan3.Notes.Should().Be(TestPlans[1].Description);
+ monthlyPlan3.Period.Unit.Should().Be(PeriodFrequencyUnit.Month);
+ monthlyPlan3.Period.Frequency.Should().Be(1);
+ monthlyPlan3.SetupCost.Should().Be(0);
+ monthlyPlan3.Trial.Should().BeNull();
+ }
+
+ [Fact]
+ public async Task WhenSearchAllInvoicesAsyncForUnsubscribedCustomer_ThenReturnsNoInvoices()
+ {
+ var (_, _, provider) = await CreateCustomerAsync();
+ var from = DateTime.UtcNow.SubtractDays(30).ToNearestMinute();
+ var to = from.AddDays(30).ToNearestMinute();
+
+ var result = await ServiceClient.SearchAllInvoicesAsync(Caller, provider, from, to,
+ new SearchOptions(), CancellationToken.None);
+
+ result.Should().BeSuccess();
+ result.Value.Count.Should().Be(0);
+ }
+
+ [Fact]
+ public async Task WhenSubscribeNewCustomerToTrialPlanImmediately_ThenSubscribesImmediately()
+ {
+ var buyer = CreateBuyer();
+ var result =
+ await ServiceClient.SubscribeAsync(Caller, buyer, SubscribeOptions.Immediately, CancellationToken.None);
+
+ var endOfTrial = DateTime.UtcNow.ToNearestSecond().AddDays(7);
+ result.Should().BeSuccess();
+ result.Value.Count.Should().Be(11);
+ result.Value.Should().Contain(ChargebeeConstants.MetadataProperties.BillingAmount,
+ ToBillingAmount(TestPlans[0]));
+ result.Value.Should().Contain(ChargebeeConstants.MetadataProperties.BillingPeriodUnit,
+ Subscription.BillingPeriodUnitEnum.Month.ToString());
+ result.Value.Should().Contain(ChargebeeConstants.MetadataProperties.BillingPeriodValue, "1");
+ result.Value.Should().NotContainKey(ChargebeeConstants.MetadataProperties.CanceledAt);
+ result.Value.Should()
+ .Contain(ChargebeeConstants.MetadataProperties.CurrencyCode, CurrencyCodes.Default.Code);
+ result.Value.Should().Contain(ChargebeeConstants.MetadataProperties.CustomerId, buyer.Subscriber.EntityId);
+ result.Value.Should().ContainKey(ChargebeeConstants.MetadataProperties.NextBillingAt)
+ .WhoseValue.Should().Match(value => value.IsNear(endOfTrial));
+ result.Value.Should().NotContainKey(ChargebeeConstants.MetadataProperties.PaymentMethodStatus);
+ result.Value.Should().NotContainKey(ChargebeeConstants.MetadataProperties.PaymentMethodType);
+ result.Value.Should().Contain(ChargebeeConstants.MetadataProperties.PlanId, TestPlans[0].PlanId);
+ result.Value.Should().Contain(ChargebeeConstants.MetadataProperties.SubscriptionDeleted, "False");
+ result.Value.Should().ContainKey(ChargebeeConstants.MetadataProperties.SubscriptionId)
+ .WhoseValue.Should().StartWith(buyer.Subscriber.EntityId);
+ result.Value.Should().Contain(ChargebeeConstants.MetadataProperties.SubscriptionStatus,
+ Subscription.StatusEnum.InTrial.ToString());
+ result.Value.Should().ContainKey(ChargebeeConstants.MetadataProperties.TrialEnd)
+ .WhoseValue.Should().Match(value => value.IsNear(endOfTrial));
+ }
+
+ [Fact]
+ public async Task WhenSubscribeExistingCustomerToTrialPlanImmediately_ThenSubscribesImmediately()
+ {
+ var (buyer, _, _) = await CreateCustomerAsync();
+
+ var result =
+ await ServiceClient.SubscribeAsync(Caller, buyer, SubscribeOptions.Immediately, CancellationToken.None);
+
+ var endOfTrial = DateTime.UtcNow.ToNearestSecond().AddDays(7);
+ result.Should().BeSuccess();
+ result.Value.Count.Should().Be(11);
+ result.Value.Should().Contain(ChargebeeConstants.MetadataProperties.BillingAmount,
+ ToBillingAmount(TestPlans[0]));
+ result.Value.Should().Contain(ChargebeeConstants.MetadataProperties.BillingPeriodUnit,
+ Subscription.BillingPeriodUnitEnum.Month.ToString());
+ result.Value.Should().Contain(ChargebeeConstants.MetadataProperties.BillingPeriodValue, "1");
+ result.Value.Should().NotContainKey(ChargebeeConstants.MetadataProperties.CanceledAt);
+ result.Value.Should()
+ .Contain(ChargebeeConstants.MetadataProperties.CurrencyCode, CurrencyCodes.Default.Code);
+ result.Value.Should().Contain(ChargebeeConstants.MetadataProperties.CustomerId, buyer.Subscriber.EntityId);
+ result.Value.Should().ContainKey(ChargebeeConstants.MetadataProperties.NextBillingAt)
+ .WhoseValue.Should().Match(value => value.IsNear(endOfTrial));
+ result.Value.Should().NotContainKey(ChargebeeConstants.MetadataProperties.PaymentMethodStatus);
+ result.Value.Should().NotContainKey(ChargebeeConstants.MetadataProperties.PaymentMethodType);
+ result.Value.Should().Contain(ChargebeeConstants.MetadataProperties.PlanId, TestPlans[0].PlanId);
+ result.Value.Should().Contain(ChargebeeConstants.MetadataProperties.SubscriptionDeleted, "False");
+ result.Value.Should().ContainKey(ChargebeeConstants.MetadataProperties.SubscriptionId)
+ .WhoseValue.Should().StartWith(buyer.Subscriber.EntityId);
+ result.Value.Should().Contain(ChargebeeConstants.MetadataProperties.SubscriptionStatus,
+ Subscription.StatusEnum.InTrial.ToString());
+ result.Value.Should().ContainKey(ChargebeeConstants.MetadataProperties.TrialEnd)
+ .WhoseValue.Should().Match(value => value.IsNear(endOfTrial));
+ }
+
+ [Fact]
+ public async Task WhenSubscribeToTrialPlanInFuture_ThenSubscribesToStartInFuture()
+ {
+ var start = DateTime.UtcNow.ToNearestSecond().AddDays(1);
+ var buyer = CreateBuyer();
+ var result =
+ await ServiceClient.SubscribeAsync(Caller, buyer, SubscribeOptions.AtScheduledTime(start),
+ CancellationToken.None);
+
+ var endOfTrial = start.AddDays(7);
+ result.Should().BeSuccess();
+ result.Value.Count.Should().Be(11);
+ result.Value.Should().Contain(ChargebeeConstants.MetadataProperties.BillingAmount,
+ ToBillingAmount(TestPlans[0]));
+ result.Value.Should().Contain(ChargebeeConstants.MetadataProperties.BillingPeriodUnit,
+ Subscription.BillingPeriodUnitEnum.Month.ToString());
+ result.Value.Should().Contain(ChargebeeConstants.MetadataProperties.BillingPeriodValue, "1");
+ result.Value.Should().NotContainKey(ChargebeeConstants.MetadataProperties.CanceledAt);
+ result.Value.Should()
+ .Contain(ChargebeeConstants.MetadataProperties.CurrencyCode, CurrencyCodes.Default.Code);
+ result.Value.Should().Contain(ChargebeeConstants.MetadataProperties.CustomerId, buyer.Subscriber.EntityId);
+ result.Value.Should().ContainKey(ChargebeeConstants.MetadataProperties.NextBillingAt)
+ .WhoseValue.Should().Match(value => value.IsNear(endOfTrial));
+ result.Value.Should().NotContainKey(ChargebeeConstants.MetadataProperties.PaymentMethodStatus);
+ result.Value.Should().NotContainKey(ChargebeeConstants.MetadataProperties.PaymentMethodType);
+ result.Value.Should().Contain(ChargebeeConstants.MetadataProperties.PlanId, TestPlans[0].PlanId);
+ result.Value.Should().Contain(ChargebeeConstants.MetadataProperties.SubscriptionDeleted, "False");
+ result.Value.Should().ContainKey(ChargebeeConstants.MetadataProperties.SubscriptionId)
+ .WhoseValue.Should().StartWith(buyer.Subscriber.EntityId);
+ result.Value.Should().Contain(ChargebeeConstants.MetadataProperties.SubscriptionStatus,
+ Subscription.StatusEnum.Future.ToString());
+ result.Value.Should().ContainKey(ChargebeeConstants.MetadataProperties.TrialEnd)
+ .WhoseValue.Should().Match(value => value.IsNear(endOfTrial));
+ }
+
+ [Fact]
+ public async Task WhenSubscribeToPaidPlanWithoutPaymentSource_ThenReturnsError()
+ {
+ var options = SubscribeOptions.Immediately;
+#if TESTINGONLY
+ options.PlanId = TestPlans[1].PlanId;
+#endif
+ var buyer = CreateBuyer();
+ var result =
+ await ServiceClient.SubscribeAsync(Caller, buyer, options, CancellationToken.None);
+
+ result.Should().BeError(ErrorCode.PreconditionViolation,
+ msg => msg.Contains("payment_method_not_present"));
+ }
+
+ [Fact]
+ public async Task WhenSubscribeToPaidPlanWithPaymentSource_ThenSubscribes()
+ {
+ var options = SubscribeOptions.Immediately;
+#if TESTINGONLY
+ options.PlanId = TestPlans[1].PlanId;
+#endif
+ var (buyer, customer, _) = await CreateCustomerAsync();
+#if TESTINGONLY
+ (await ServiceClient.CreateCustomerPaymentMethod(Caller, customer.Id, CancellationToken.None))
+ .ThrowOnError();
+#endif
+
+ var result =
+ await ServiceClient.SubscribeAsync(Caller, buyer, options, CancellationToken.None);
+
+ var nextBilling = DateTime.UtcNow.ToNearestSecond().AddMonths(1);
+ result.Should().BeSuccess();
+ result.Value.Count.Should().Be(12);
+ result.Value.Should().Contain(ChargebeeConstants.MetadataProperties.BillingAmount,
+ ToBillingAmount(TestPlans[1]));
+ result.Value.Should().Contain(ChargebeeConstants.MetadataProperties.BillingPeriodUnit,
+ Subscription.BillingPeriodUnitEnum.Month.ToString());
+ result.Value.Should().Contain(ChargebeeConstants.MetadataProperties.BillingPeriodValue, "1");
+ result.Value.Should().NotContainKey(ChargebeeConstants.MetadataProperties.CanceledAt);
+ result.Value.Should()
+ .Contain(ChargebeeConstants.MetadataProperties.CurrencyCode, CurrencyCodes.Default.Code);
+ result.Value.Should().Contain(ChargebeeConstants.MetadataProperties.CustomerId, buyer.Subscriber.EntityId);
+ result.Value.Should().ContainKey(ChargebeeConstants.MetadataProperties.NextBillingAt)
+ .WhoseValue.Should().Match(value => value.IsNear(nextBilling));
+ result.Value.Should().Contain(ChargebeeConstants.MetadataProperties.PaymentMethodStatus,
+ PaymentSource.StatusEnum.Valid.ToString());
+ result.Value.Should().Contain(ChargebeeConstants.MetadataProperties.PaymentMethodType,
+ Customer.CustomerPaymentMethod.TypeEnum.Card.ToString());
+ result.Value.Should().Contain(ChargebeeConstants.MetadataProperties.PlanId, "Paid-USD-Monthly");
+ result.Value.Should().Contain(ChargebeeConstants.MetadataProperties.SubscriptionDeleted, "False");
+ result.Value.Should().ContainKey(ChargebeeConstants.MetadataProperties.SubscriptionId)
+ .WhoseValue.Should().StartWith(buyer.Subscriber.EntityId);
+ result.Value.Should().Contain(ChargebeeConstants.MetadataProperties.SubscriptionStatus,
+ Subscription.StatusEnum.Active.ToString());
+ result.Value.Should().NotContainKey(ChargebeeConstants.MetadataProperties.TrialEnd);
+ }
+
+ [Fact]
+ public async Task WhenRestoreBuyerAndCustomerExists_ThenUpdates()
+ {
+ var (buyer, customer, _) = await CreateCustomerAsync();
+#if TESTINGONLY
+ (await ServiceClient.CreateCustomerPaymentMethod(Caller, customer.Id, CancellationToken.None))
+ .ThrowOnError();
+#endif
+
+ var result =
+ await ServiceClient.RestoreBuyerAsync(Caller, buyer, CancellationToken.None);
+
+ result.Should().BeSuccess();
+ result.Value.Count.Should().Be(3);
+ result.Value.Should().Contain(ChargebeeConstants.MetadataProperties.CustomerId, buyer.Subscriber.EntityId);
+ result.Value.Should().Contain(ChargebeeConstants.MetadataProperties.PaymentMethodStatus,
+ PaymentSource.StatusEnum.Valid.ToString());
+ result.Value.Should().Contain(ChargebeeConstants.MetadataProperties.PaymentMethodType,
+ Customer.CustomerPaymentMethod.TypeEnum.Card.ToString());
+ }
+
+ [Fact]
+ public async Task WhenRestoreBuyerAndCustomerDeleted_ThenReCreates()
+ {
+ var buyer = CreateBuyer();
+
+ var result =
+ await ServiceClient.RestoreBuyerAsync(Caller, buyer, CancellationToken.None);
+
+ result.Should().BeSuccess();
+ result.Value.Count.Should().Be(1);
+ result.Value.Should().Contain(ChargebeeConstants.MetadataProperties.CustomerId, buyer.Subscriber.EntityId);
+ }
+ }
+
+ [Trait("Category", "Integration.External")]
+ [Collection("External")]
+ public class GivenASubscription : ChargebeeHttpServiceClientSetupSpec
+ {
+ public GivenASubscription(ExternalApiSetup setup) : base(setup)
+ {
+ }
+
+ [Fact]
+ public async Task WhenSearchAllInvoicesAsyncForSubscribedCustomer_ThenReturnsOneInvoice()
+ {
+ var now = DateTime.UtcNow.ToNearestSecond();
+ var from = now.ToNearestMinute();
+ var to = from.AddMonths(1).ToNearestMinute();
+ var provider = await SubscribeCustomerWithCardAsync(TestPlans[1].PlanId);
+
+ var result = await ServiceClient.SearchAllInvoicesAsync(Caller, provider, from, to,
+ new SearchOptions(), CancellationToken.None);
+
+ result.Should().BeSuccess();
+ result.Value.Count.Should().Be(1);
+ result.Value[0].Id.Should().NotBeEmpty();
+ result.Value[0].Amount.Should().Be(100);
+ result.Value[0].Currency.Should().Be(CurrencyCodes.Default.Code);
+ result.Value[0].IncludesTax.Should().BeFalse();
+ result.Value[0].InvoicedOnUtc!.Value.Should().BeNear(now, TimeSpan.FromMinutes(1));
+ result.Value[0].LineItems.Count.Should().Be(1);
+ result.Value[0].LineItems[0].Amount.Should().Be(100);
+ result.Value[0].LineItems[0].Currency.Should().Be(CurrencyCodes.Default.Code);
+ result.Value[0].LineItems[0].Description.Should().Be("Paid");
+ result.Value[0].LineItems[0].IsTaxed.Should().BeFalse();
+ result.Value[0].LineItems[0].Reference.Should().NotBeEmpty();
+ result.Value[0].LineItems[0].TaxAmount.Should().Be(0);
+ result.Value[0].Notes.Count.Should().Be(1);
+ result.Value[0].Notes[0].Description.Should().Be("Paid plan");
+ result.Value[0].Payment!.Amount.Should().Be(100);
+ result.Value[0].Payment!.Currency.Should().Be(CurrencyCodes.Default.Code);
+ result.Value[0].Payment!.PaidOnUtc!.Value.Should().BeNear(now, TimeSpan.FromMinutes(1));
+ result.Value[0].Payment!.Reference.Should().NotBeEmpty();
+ result.Value[0].PeriodEndUtc!.Value.Should().BeNear(to, TimeSpan.FromMinutes(1));
+ result.Value[0].PeriodStartUtc!.Value.Should().BeNear(from, TimeSpan.FromMinutes(1));
+ result.Value[0].Status.Should().Be(InvoiceStatus.Paid);
+ result.Value[0].TaxAmount.Should().Be(0);
+ }
+
+ [Fact]
+ public async Task WhenChangeSubscriptionPlanAsync_ThenUpgradesPlan()
+ {
+ var provider = await SubscribeCustomerWithCardAsync(TestPlans[1].PlanId);
+
+ var result = await ServiceClient.ChangeSubscriptionPlanAsync(Caller, new ChangePlanOptions
+ {
+ Subscriber = new Subscriber
+ {
+ EntityId = "anentityid",
+ EntityType = "anentitytype"
+ },
+ PlanId = TestPlans[2].PlanId
+ }, provider, CancellationToken.None);
+
+ result.Should().BeSuccess();
+ result.Value.Count.Should().Be(10);
+ result.Value.GetValueOrDefault(ChargebeeConstants.MetadataProperties.PlanId).Should()
+ .Be(TestPlans[2].PlanId);
+ result.Value.GetValueOrDefault(ChargebeeConstants.MetadataProperties.SubscriptionStatus).Should()
+ .Be(Subscription.StatusEnum.Active.ToString());
+ }
+
+ [Fact]
+ public async Task WhenChangeSubscriptionPlanAsyncAndCancelled_ThenReactivatesAndUpgradesPlan()
+ {
+ var provider = await SubscribeCustomerWithCardAsync(TestPlans[1].PlanId);
+ var subscriptionId = provider.State.GetValueOrDefault(ChargebeeConstants.MetadataProperties.SubscriptionId);
+ provider = await CancelSubscriptionAsync(provider, CancelSubscriptionOptions.Immediately);
+
+ var result = await ServiceClient.ChangeSubscriptionPlanAsync(Caller, new ChangePlanOptions
+ {
+ Subscriber = new Subscriber
+ {
+ EntityId = "anentityid",
+ EntityType = "anentitytype"
+ },
+ PlanId = TestPlans[2].PlanId
+ }, provider, CancellationToken.None);
+
+ result.Should().BeSuccess();
+ result.Value.Count.Should().Be(10);
+ result.Value.GetValueOrDefault(ChargebeeConstants.MetadataProperties.SubscriptionId).Should()
+ .Be(subscriptionId);
+ result.Value.GetValueOrDefault(ChargebeeConstants.MetadataProperties.SubscriptionStatus).Should()
+ .Be(Subscription.StatusEnum.Active.ToString());
+ result.Value.GetValueOrDefault(ChargebeeConstants.MetadataProperties.CanceledAt).Should().BeNull();
+ result.Value.GetValueOrDefault(ChargebeeConstants.MetadataProperties.PlanId).Should()
+ .Be(TestPlans[2].PlanId);
+ }
+
+ [Fact]
+ public async Task WhenChangeSubscriptionPlanAsyncAndCancelling_ThenRemoveCancellationAndUpgradesPlan()
+ {
+ var provider = await SubscribeCustomerWithCardAsync(TestPlans[1].PlanId);
+ var subscriptionId = provider.State.GetValueOrDefault(ChargebeeConstants.MetadataProperties.SubscriptionId);
+ provider = await CancelSubscriptionAsync(provider, CancelSubscriptionOptions.EndOfTerm);
+
+ var result = await ServiceClient.ChangeSubscriptionPlanAsync(Caller, new ChangePlanOptions
+ {
+ Subscriber = new Subscriber
+ {
+ EntityId = "anentityid",
+ EntityType = "anentitytype"
+ },
+ PlanId = TestPlans[2].PlanId
+ }, provider, CancellationToken.None);
+
+ result.Should().BeSuccess();
+ result.Value.Count.Should().Be(10);
+ result.Value.GetValueOrDefault(ChargebeeConstants.MetadataProperties.SubscriptionId).Should()
+ .Be(subscriptionId);
+ result.Value.GetValueOrDefault(ChargebeeConstants.MetadataProperties.SubscriptionStatus).Should()
+ .Be(Subscription.StatusEnum.Active.ToString());
+ result.Value.GetValueOrDefault(ChargebeeConstants.MetadataProperties.CanceledAt).Should().BeNull();
+ result.Value.GetValueOrDefault(ChargebeeConstants.MetadataProperties.PlanId).Should()
+ .Be(TestPlans[2].PlanId);
+ }
+
+ [Fact]
+ public async Task WhenChangeSubscriptionPlanAsyncAndUnsubscribed_ThenResubscribesAndUpgradesPlan()
+ {
+ var provider = await SubscribeCustomerWithCardAsync(TestPlans[1].PlanId);
+ var subscriptionId = provider.State.GetValueOrDefault(ChargebeeConstants.MetadataProperties.SubscriptionId);
+ provider = await UnsubscribeAsync(provider);
+
+ var result = await ServiceClient.ChangeSubscriptionPlanAsync(Caller, new ChangePlanOptions
+ {
+ Subscriber = new Subscriber
+ {
+ EntityId = "anentityid",
+ EntityType = "anentitytype"
+ },
+ PlanId = TestPlans[2].PlanId
+ }, provider, CancellationToken.None);
+
+ result.Should().BeSuccess();
+ result.Value.Count.Should().Be(10);
+ result.Value.GetValueOrDefault(ChargebeeConstants.MetadataProperties.SubscriptionId).Should()
+ .NotBe(subscriptionId);
+ result.Value.GetValueOrDefault(ChargebeeConstants.MetadataProperties.SubscriptionStatus).Should()
+ .Be(Subscription.StatusEnum.Active.ToString());
+ result.Value.GetValueOrDefault(ChargebeeConstants.MetadataProperties.CanceledAt).Should().BeNull();
+ result.Value.GetValueOrDefault(ChargebeeConstants.MetadataProperties.PlanId).Should()
+ .Be(TestPlans[2].PlanId);
+ }
+
+ [Fact]
+ public async Task WhenCancelSubscriptionAsyncAndImmediately_ThenCancelledImmediately()
+ {
+ var provider = await SubscribeCustomerWithCardAsync(TestPlans[1].PlanId);
+ var subscriptionId = provider.State.GetValueOrDefault(ChargebeeConstants.MetadataProperties.SubscriptionId);
+ var now = DateTime.UtcNow.ToNearestSecond();
+
+ var result = await ServiceClient.CancelSubscriptionAsync(Caller, CancelSubscriptionOptions.Immediately,
+ provider, CancellationToken.None);
+
+ result.Should().BeSuccess();
+ result.Value.Count.Should().Be(10);
+ result.Value.GetValueOrDefault(ChargebeeConstants.MetadataProperties.SubscriptionId).Should()
+ .Be(subscriptionId);
+ result.Value.GetValueOrDefault(ChargebeeConstants.MetadataProperties.SubscriptionStatus).Should()
+ .Be(Subscription.StatusEnum.Cancelled.ToString());
+ result.Value.GetValueOrDefault(ChargebeeConstants.MetadataProperties.CanceledAt).Should()
+ .Match(x => x.IsNear(now));
+ }
+
+ [Fact]
+ public async Task WhenCancelSubscriptionAsyncAndEndOfTerm_ThenCancelsLater()
+ {
+ var provider = await SubscribeCustomerWithCardAsync(TestPlans[1].PlanId);
+ var subscriptionId = provider.State.GetValueOrDefault(ChargebeeConstants.MetadataProperties.SubscriptionId);
+ var now = DateTime.UtcNow.ToNearestSecond();
+ var endOfTerm = now.AddMonths(1);
+
+ var result = await ServiceClient.CancelSubscriptionAsync(Caller, CancelSubscriptionOptions.EndOfTerm,
+ provider, CancellationToken.None);
+
+ result.Should().BeSuccess();
+ result.Value.Count.Should().Be(10);
+ result.Value.GetValueOrDefault(ChargebeeConstants.MetadataProperties.SubscriptionId).Should()
+ .Be(subscriptionId);
+ result.Value.GetValueOrDefault(ChargebeeConstants.MetadataProperties.SubscriptionStatus).Should()
+ .Be(Subscription.StatusEnum.NonRenewing.ToString());
+ result.Value.GetValueOrDefault(ChargebeeConstants.MetadataProperties.CanceledAt).Should()
+ .Match(x => x.IsNear(endOfTerm));
+ }
+
+ [Fact]
+ public async Task WhenCancelSubscriptionAsyncInFuture_ThenCancelsInFuture()
+ {
+ var provider = await SubscribeCustomerWithCardAsync(TestPlans[1].PlanId);
+ var subscriptionId = provider.State.GetValueOrDefault(ChargebeeConstants.MetadataProperties.SubscriptionId);
+ var now = DateTime.UtcNow.ToNearestSecond();
+ var future = now.AddDays(2);
+
+ var result = await ServiceClient.CancelSubscriptionAsync(Caller, new CancelSubscriptionOptions
+ {
+ CancelWhen = CancelSubscriptionSchedule.Scheduled,
+ FutureTime = future
+ },
+ provider, CancellationToken.None);
+
+ result.Should().BeSuccess();
+ result.Value.Count.Should().Be(10);
+ result.Value.GetValueOrDefault(ChargebeeConstants.MetadataProperties.SubscriptionId).Should()
+ .Be(subscriptionId);
+ result.Value.GetValueOrDefault(ChargebeeConstants.MetadataProperties.SubscriptionStatus).Should()
+ .Be(Subscription.StatusEnum.NonRenewing.ToString());
+ result.Value.GetValueOrDefault(ChargebeeConstants.MetadataProperties.CanceledAt).Should()
+ .Match(x => x.IsNear(future));
+ }
+
+ [Fact]
+ public async Task WhenTransferSubscriptionAsyncAndUnsubscribed_ThenResubscribesAndTransfers()
+ {
+ var provider = await SubscribeCustomerWithCardAsync(TestPlans[1].PlanId);
+ var customerId = provider.State.GetValueOrDefault(ChargebeeConstants.MetadataProperties.CustomerId)!;
+ var subscriptionId = provider.State.GetValueOrDefault(ChargebeeConstants.MetadataProperties.SubscriptionId);
+ provider = await UnsubscribeAsync(provider);
+
+ var result = await ServiceClient.TransferSubscriptionAsync(Caller, new TransferSubscriptionOptions
+ {
+ TransfereeBuyer = new SubscriptionBuyer
+ {
+ Address = new ProfileAddress
+ {
+ CountryCode = CountryCodes.Default.ToString()
+ },
+ Subscriber = new Subscriber
+ {
+ EntityId = customerId,
+ EntityType = "anentitytype"
+ },
+ EmailAddress = "anotheruser@company.com",
+ Id = "anothertestuserid",
+ Name = new PersonName
+ {
+ FirstName = "anewfirstname",
+ LastName = "anewlastname"
+ }
+ },
+ PlanId = TestPlans[2].PlanId
+ },
+ provider, CancellationToken.None);
+
+ result.Should().BeSuccess();
+ result.Value.Count.Should().Be(12);
+ result.Value.GetValueOrDefault(ChargebeeConstants.MetadataProperties.CustomerId).Should().Be(customerId);
+ result.Value.GetValueOrDefault(ChargebeeConstants.MetadataProperties.SubscriptionId).Should()
+ .NotBe(subscriptionId);
+ result.Value.GetValueOrDefault(ChargebeeConstants.MetadataProperties.SubscriptionStatus).Should()
+ .Be(Subscription.StatusEnum.Active.ToString());
+ result.Value.GetValueOrDefault(ChargebeeConstants.MetadataProperties.CanceledAt).Should().BeNull();
+ }
+
+ [Fact]
+ public async Task WhenTransferSubscriptionAsync_ThenTransfers()
+ {
+ var provider = await SubscribeCustomerWithCardAsync(TestPlans[1].PlanId);
+ var customerId = provider.State.GetValueOrDefault(ChargebeeConstants.MetadataProperties.CustomerId)!;
+ var subscriptionId = provider.State.GetValueOrDefault(ChargebeeConstants.MetadataProperties.SubscriptionId);
+
+ var result = await ServiceClient.TransferSubscriptionAsync(Caller, new TransferSubscriptionOptions
+ {
+ TransfereeBuyer = new SubscriptionBuyer
+ {
+ Address = new ProfileAddress
+ {
+ CountryCode = CountryCodes.Default.ToString()
+ },
+ Subscriber = new Subscriber
+ {
+ EntityId = customerId,
+ EntityType = "anentitytype"
+ },
+ EmailAddress = "anotheruser@company.com",
+ Id = "anothertestuserid",
+ Name = new PersonName
+ {
+ FirstName = "anewfirstname",
+ LastName = "anewlastname"
+ }
+ },
+ PlanId = TestPlans[2].PlanId
+ },
+ provider, CancellationToken.None);
+
+ result.Should().BeSuccess();
+ result.Value.Count.Should().Be(12);
+ result.Value.GetValueOrDefault(ChargebeeConstants.MetadataProperties.CustomerId).Should().Be(customerId);
+ result.Value.GetValueOrDefault(ChargebeeConstants.MetadataProperties.SubscriptionId).Should()
+ .Be(subscriptionId);
+ result.Value.GetValueOrDefault(ChargebeeConstants.MetadataProperties.SubscriptionStatus).Should()
+ .Be(Subscription.StatusEnum.Active.ToString());
+ result.Value.GetValueOrDefault(ChargebeeConstants.MetadataProperties.CanceledAt).Should().BeNull();
+ }
+ }
+}
+
+internal static class TestingExtensions
+{
+ public static bool IsNear(this string value, DateTime comparedTo)
+ {
+ return value.FromIso8601().IsNear(comparedTo, TimeSpan.FromMinutes(1));
+ }
+}
\ No newline at end of file
diff --git a/src/Infrastructure.Shared.IntegrationTests/appsettings.Testing.json b/src/Infrastructure.Shared.IntegrationTests/appsettings.Testing.json
index fc063c36..d0393754 100644
--- a/src/Infrastructure.Shared.IntegrationTests/appsettings.Testing.json
+++ b/src/Infrastructure.Shared.IntegrationTests/appsettings.Testing.json
@@ -12,6 +12,9 @@
"RootPath": "./saastack/testing/external"
}
},
+ "Chargebee": {
+ "TODO": "You need to provide settings to a real 'testingonly' instance of Chargebee here, or in appsettings.Testing,local.json"
+ },
"Flagsmith": {
"TODO": "You need to provide settings to a real 'testingonly' instance of Flagsmith here, or in appsettings.Testing,local.json",
"BaseUrl": "https://edge.api.flagsmith.com/api/v1/",
diff --git a/src/Infrastructure.Shared.UnitTests/ApplicationServices/External/ChargebeeBillingProvider.ChargebeeHttpServiceClientSpec.cs b/src/Infrastructure.Shared.UnitTests/ApplicationServices/External/ChargebeeBillingProvider.ChargebeeHttpServiceClientSpec.cs
new file mode 100644
index 00000000..5ad77227
--- /dev/null
+++ b/src/Infrastructure.Shared.UnitTests/ApplicationServices/External/ChargebeeBillingProvider.ChargebeeHttpServiceClientSpec.cs
@@ -0,0 +1,1370 @@
+using Application.Interfaces;
+using Application.Resources.Shared;
+using Application.Services.Shared;
+using ChargeBee.Models;
+using ChargeBee.Models.Enums;
+using Common;
+using Common.Extensions;
+using Domain.Shared.Subscriptions;
+using FluentAssertions;
+using Infrastructure.Shared.ApplicationServices.External;
+using Moq;
+using UnitTesting.Common;
+using Xunit;
+using PersonName = Application.Resources.Shared.PersonName;
+using Subscription = ChargeBee.Models.Subscription;
+using Invoice = ChargeBee.Models.Invoice;
+
+namespace Infrastructure.Shared.UnitTests.ApplicationServices.External;
+
+[Trait("Category", "Unit")]
+public class ChargebeeHttpServiceClientSpec
+{
+ private readonly Mock _caller;
+ private readonly ChargebeeHttpServiceClient _client;
+ private readonly Mock _pricingPlanCache;
+ private readonly Mock _serviceClient;
+
+ public ChargebeeHttpServiceClientSpec()
+ {
+ var recorder = new Mock();
+ _caller = new Mock();
+ _serviceClient = new Mock();
+ _pricingPlanCache = new Mock();
+ _pricingPlanCache.Setup(ppc => ppc.GetAsync(It.IsAny()))
+ .ReturnsAsync(Optional.None);
+
+ _client = new ChargebeeHttpServiceClient(recorder.Object, _serviceClient.Object, _pricingPlanCache.Object,
+ "aninitialplanid", "afamilyid");
+ }
+
+ [Fact]
+ public async Task WhenSubscribeAsyncAndImmediatelyWithDate_ThenReturnsError()
+ {
+ var buyer = CreateBuyer("abuyerid");
+ var options = new SubscribeOptions
+ {
+ StartWhen = StartSubscriptionSchedule.Immediately,
+ FutureTime = DateTime.UtcNow
+ };
+ _serviceClient.Setup(sc =>
+ sc.FindCustomerByIdAsync(It.IsAny(), It.IsAny(), It.IsAny()))
+ .ReturnsAsync(Optional.None);
+ _serviceClient.Setup(sc => sc.CreateCustomerForBuyerAsync(It.IsAny(), It.IsAny(),
+ It.IsAny(), It.IsAny()))
+ .ReturnsAsync(CreateCustomer("acustomerid", false));
+ _serviceClient.Setup(sc => sc.CreateSubscriptionForCustomerAsync(It.IsAny(), It.IsAny(),
+ It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny>(),
+ It.IsAny()))
+ .ReturnsAsync(CreateSubscription(CreateCustomer("acustomerid", false), "asubscriptionid"));
+
+ var result = await _client.SubscribeAsync(_caller.Object, buyer, options, CancellationToken.None);
+
+ result.Should().BeError(ErrorCode.Validation, Resources.ChargebeeHttpServiceClient_Subscribe_ScheduleInvalid);
+ }
+
+ [Fact]
+ public async Task WhenSubscribeAsyncAndScheduledInPast_ThenReturnsError()
+ {
+ var buyer = CreateBuyer("abuyerId");
+ var options = new SubscribeOptions
+ {
+ StartWhen = StartSubscriptionSchedule.Scheduled,
+ FutureTime = DateTime.UtcNow.SubtractHours(1)
+ };
+ _serviceClient.Setup(sc =>
+ sc.FindCustomerByIdAsync(It.IsAny(), It.IsAny(), It.IsAny()))
+ .ReturnsAsync(Optional.None);
+ _serviceClient.Setup(sc => sc.CreateCustomerForBuyerAsync(It.IsAny(), It.IsAny(),
+ It.IsAny(), It.IsAny()))
+ .ReturnsAsync(CreateCustomer("acustomerid", false));
+ _serviceClient.Setup(sc => sc.CreateSubscriptionForCustomerAsync(It.IsAny(), It.IsAny(),
+ It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny>(),
+ It.IsAny()))
+ .ReturnsAsync(CreateSubscription(CreateCustomer("acustomerid", false), "asubscriptionid"));
+
+ var result = await _client.SubscribeAsync(_caller.Object, buyer, options, CancellationToken.None);
+
+ result.Should().BeError(ErrorCode.Validation, Resources.ChargebeeHttpServiceClient_Subscribe_ScheduleInvalid);
+ }
+
+ [Fact]
+ public async Task
+ WhenSubscribeAsyncWithImmediatelyAndCustomerExists_ThenCreatesSubscriptionForCustomerAndUpdatesBillingDetails()
+ {
+ var buyer = CreateBuyer("abuyerId");
+ var options = new SubscribeOptions
+ {
+ StartWhen = StartSubscriptionSchedule.Immediately,
+ FutureTime = null
+ };
+ var datum = DateTime.UtcNow.ToNearestSecond();
+ var customer = CreateCustomer("acustomerid", true);
+ var subscription = CreateSubscription(customer, "asubscriptionid", "aplanid", datum, datum, datum);
+ _serviceClient.Setup(sc =>
+ sc.FindCustomerByIdAsync(It.IsAny(), It.IsAny(), It.IsAny()))
+ .ReturnsAsync(customer.ToOptional());
+ _serviceClient.Setup(sc => sc.UpdateCustomerForBuyerAsync(It.IsAny(), It.IsAny(),
+ It.IsAny(), It.IsAny()))
+ .ReturnsAsync(customer);
+ _serviceClient.Setup(sc => sc.UpdateCustomerForBuyerBillingAddressAsync(It.IsAny(),
+ It.IsAny(),
+ It.IsAny(), It.IsAny()))
+ .ReturnsAsync(customer);
+ _serviceClient.Setup(sc => sc.CreateSubscriptionForCustomerAsync(It.IsAny(), It.IsAny(),
+ It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny>(),
+ It.IsAny()))
+ .ReturnsAsync(subscription);
+
+ var result = await _client.SubscribeAsync(_caller.Object, buyer, options, CancellationToken.None);
+
+ result.Should().BeSuccess();
+ result.Value[ChargebeeConstants.MetadataProperties.CustomerId].Should().Be("acustomerid");
+ result.Value[ChargebeeConstants.MetadataProperties.PaymentMethodStatus].Should()
+ .Be(Customer.CustomerPaymentMethod.StatusEnum.Valid.ToString());
+ result.Value[ChargebeeConstants.MetadataProperties.PaymentMethodType].Should()
+ .Be(Customer.CustomerPaymentMethod.TypeEnum.Card.ToString());
+ result.Value[ChargebeeConstants.MetadataProperties.SubscriptionId].Should().Be("asubscriptionid");
+ result.Value[ChargebeeConstants.MetadataProperties.BillingPeriodValue].Should().Be("0");
+ result.Value[ChargebeeConstants.MetadataProperties.BillingPeriodUnit].Should()
+ .Be(Subscription.BillingPeriodUnitEnum.Month.ToString());
+ result.Value[ChargebeeConstants.MetadataProperties.SubscriptionStatus].Should()
+ .Be(Subscription.StatusEnum.Active.ToString());
+ result.Value[ChargebeeConstants.MetadataProperties.SubscriptionDeleted].Should().Be(false.ToString());
+ result.Value[ChargebeeConstants.MetadataProperties.CurrencyCode].Should().Be("USD");
+ result.Value[ChargebeeConstants.MetadataProperties.PlanId].Should().Be("aplanid");
+ result.Value[ChargebeeConstants.MetadataProperties.BillingAmount].Should().Be("0");
+ result.Value[ChargebeeConstants.MetadataProperties.TrialEnd].Should().Be(datum.ToIso8601());
+ result.Value[ChargebeeConstants.MetadataProperties.NextBillingAt].Should().Be(datum.ToIso8601());
+ result.Value[ChargebeeConstants.MetadataProperties.CanceledAt].Should().Be(datum.ToIso8601());
+ _serviceClient.Verify(sc =>
+ sc.FindCustomerByIdAsync(_caller.Object, "anentityid", It.IsAny()));
+ _serviceClient.Verify(
+ sc => sc.CreateCustomerForBuyerAsync(It.IsAny(), It.IsAny(),
+ It.IsAny(), It.IsAny()), Times.Never);
+ _serviceClient.Verify(sc => sc.UpdateCustomerForBuyerAsync(_caller.Object, "anentityid",
+ buyer, It.IsAny()));
+ _serviceClient.Verify(sc => sc.UpdateCustomerForBuyerBillingAddressAsync(_caller.Object, "anentityid",
+ buyer, It.IsAny()));
+ _serviceClient.Verify(sc => sc.CreateSubscriptionForCustomerAsync(_caller.Object, "acustomerid",
+ It.Is(sub =>
+ sub.EntityId == "anentityid"
+ && sub.EntityType == "anentitytype"
+ ), "aninitialplanid", Optional.None, Optional.None,
+ It.IsAny()));
+ }
+
+ [Fact]
+ public async Task
+ WhenSubscribeAsyncAndScheduledAndCustomerExists_ThenCreatesSubscriptionForCustomerAndUpdatesBillingDetails()
+ {
+ var buyer = CreateBuyer("abuyerId");
+ var starts = DateTime.UtcNow.AddMinutes(1).ToNearestSecond();
+ var options = new SubscribeOptions
+ {
+ StartWhen = StartSubscriptionSchedule.Scheduled,
+ FutureTime = starts
+ };
+ var datum = DateTime.UtcNow.ToNearestSecond();
+ var customer = CreateCustomer("acustomerid", true);
+ var subscription = CreateSubscription(customer, "asubscriptionid", "aplanid", datum, datum, datum);
+ _serviceClient.Setup(sc =>
+ sc.FindCustomerByIdAsync(It.IsAny(), It.IsAny(), It.IsAny()))
+ .ReturnsAsync(customer.ToOptional());
+ _serviceClient.Setup(sc => sc.UpdateCustomerForBuyerAsync(It.IsAny(), It.IsAny(),
+ It.IsAny(), It.IsAny()))
+ .ReturnsAsync(customer);
+ _serviceClient.Setup(sc => sc.UpdateCustomerForBuyerBillingAddressAsync(It.IsAny(),
+ It.IsAny(),
+ It.IsAny(), It.IsAny()))
+ .ReturnsAsync(customer);
+ _serviceClient.Setup(sc => sc.CreateSubscriptionForCustomerAsync(It.IsAny(), It.IsAny(),
+ It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny>(),
+ It.IsAny()))
+ .ReturnsAsync(subscription);
+
+ var result = await _client.SubscribeAsync(_caller.Object, buyer, options, CancellationToken.None);
+
+ result.Should().BeSuccess();
+ result.Value[ChargebeeConstants.MetadataProperties.CustomerId].Should().Be("acustomerid");
+ result.Value[ChargebeeConstants.MetadataProperties.PaymentMethodStatus].Should()
+ .Be(Customer.CustomerPaymentMethod.StatusEnum.Valid.ToString());
+ result.Value[ChargebeeConstants.MetadataProperties.PaymentMethodType].Should()
+ .Be(Customer.CustomerPaymentMethod.TypeEnum.Card.ToString());
+ result.Value[ChargebeeConstants.MetadataProperties.SubscriptionId].Should().Be("asubscriptionid");
+ result.Value[ChargebeeConstants.MetadataProperties.BillingPeriodValue].Should().Be("0");
+ result.Value[ChargebeeConstants.MetadataProperties.BillingPeriodUnit].Should()
+ .Be(Subscription.BillingPeriodUnitEnum.Month.ToString());
+ result.Value[ChargebeeConstants.MetadataProperties.SubscriptionStatus].Should()
+ .Be(Subscription.StatusEnum.Active.ToString());
+ result.Value[ChargebeeConstants.MetadataProperties.SubscriptionDeleted].Should().Be(false.ToString());
+ result.Value[ChargebeeConstants.MetadataProperties.CurrencyCode].Should().Be("USD");
+ result.Value[ChargebeeConstants.MetadataProperties.PlanId].Should().Be("aplanid");
+ result.Value[ChargebeeConstants.MetadataProperties.BillingAmount].Should().Be("0");
+ result.Value[ChargebeeConstants.MetadataProperties.TrialEnd].Should().Be(datum.ToIso8601());
+ result.Value[ChargebeeConstants.MetadataProperties.NextBillingAt].Should().Be(datum.ToIso8601());
+ result.Value[ChargebeeConstants.MetadataProperties.CanceledAt].Should().Be(datum.ToIso8601());
+ _serviceClient.Verify(sc =>
+ sc.FindCustomerByIdAsync(_caller.Object, "anentityid", It.IsAny()));
+ _serviceClient.Verify(
+ sc => sc.CreateCustomerForBuyerAsync(It.IsAny(), It.IsAny(),
+ It.IsAny(), It.IsAny()), Times.Never);
+ _serviceClient.Verify(sc => sc.UpdateCustomerForBuyerAsync(_caller.Object, "anentityid",
+ buyer, It.IsAny()));
+ _serviceClient.Verify(sc => sc.UpdateCustomerForBuyerBillingAddressAsync(_caller.Object, "anentityid",
+ buyer, It.IsAny()));
+ _serviceClient.Verify(sc => sc.CreateSubscriptionForCustomerAsync(_caller.Object, "acustomerid",
+ It.Is(sub =>
+ sub.EntityId == "anentityid"
+ && sub.EntityType == "anentitytype"
+ ), "aninitialplanid", starts.ToUnixSeconds(), Optional.None,
+ It.IsAny()));
+ }
+
+ [Fact]
+ public async Task WhenSubscribeAsyncAndCustomerNotExists_ThenCreatesCustomerAndSubscription()
+ {
+ var buyer = CreateBuyer("abuyerId");
+ var starts = DateTime.UtcNow.AddMinutes(1).ToNearestSecond();
+ var options = new SubscribeOptions
+ {
+ StartWhen = StartSubscriptionSchedule.Scheduled,
+ FutureTime = starts
+ };
+ var datum = DateTime.UtcNow.ToNearestSecond();
+ var customer = CreateCustomer("acustomerid", true);
+ var subscription = CreateSubscription(customer, "asubscriptionid", "aplanid", datum, datum, datum);
+ _serviceClient.Setup(sc =>
+ sc.FindCustomerByIdAsync(It.IsAny(), It.IsAny(), It.IsAny()))
+ .ReturnsAsync(Optional.None);
+ _serviceClient.Setup(sc => sc.CreateCustomerForBuyerAsync(It.IsAny(), It.IsAny(),
+ It.IsAny(), It.IsAny()))
+ .ReturnsAsync(customer);
+ _serviceClient.Setup(sc => sc.CreateSubscriptionForCustomerAsync(It.IsAny(), It.IsAny(),
+ It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny>(),
+ It.IsAny()))
+ .ReturnsAsync(subscription);
+
+ var result = await _client.SubscribeAsync(_caller.Object, buyer, options, CancellationToken.None);
+
+ result.Should().BeSuccess();
+ result.Value[ChargebeeConstants.MetadataProperties.CustomerId].Should().Be("acustomerid");
+ result.Value[ChargebeeConstants.MetadataProperties.PaymentMethodStatus].Should()
+ .Be(Customer.CustomerPaymentMethod.StatusEnum.Valid.ToString());
+ result.Value[ChargebeeConstants.MetadataProperties.PaymentMethodType].Should()
+ .Be(Customer.CustomerPaymentMethod.TypeEnum.Card.ToString());
+ result.Value[ChargebeeConstants.MetadataProperties.SubscriptionId].Should().Be("asubscriptionid");
+ result.Value[ChargebeeConstants.MetadataProperties.BillingPeriodValue].Should().Be("0");
+ result.Value[ChargebeeConstants.MetadataProperties.BillingPeriodUnit].Should()
+ .Be(Subscription.BillingPeriodUnitEnum.Month.ToString());
+ result.Value[ChargebeeConstants.MetadataProperties.SubscriptionStatus].Should()
+ .Be(Subscription.StatusEnum.Active.ToString());
+ result.Value[ChargebeeConstants.MetadataProperties.SubscriptionDeleted].Should().Be(false.ToString());
+ result.Value[ChargebeeConstants.MetadataProperties.CurrencyCode].Should().Be("USD");
+ result.Value[ChargebeeConstants.MetadataProperties.PlanId].Should().Be("aplanid");
+ result.Value[ChargebeeConstants.MetadataProperties.BillingAmount].Should().Be("0");
+ result.Value[ChargebeeConstants.MetadataProperties.TrialEnd].Should().Be(datum.ToIso8601());
+ result.Value[ChargebeeConstants.MetadataProperties.NextBillingAt].Should().Be(datum.ToIso8601());
+ result.Value[ChargebeeConstants.MetadataProperties.CanceledAt].Should().Be(datum.ToIso8601());
+ _serviceClient.Verify(sc =>
+ sc.FindCustomerByIdAsync(_caller.Object, "anentityid", It.IsAny()));
+ _serviceClient.Verify(sc => sc.CreateCustomerForBuyerAsync(_caller.Object, "anentityid",
+ It.IsAny(), It.IsAny()));
+ _serviceClient.Verify(
+ sc => sc.UpdateCustomerForBuyerAsync(It.IsAny(), It.IsAny(),
+ It.IsAny(), It.IsAny()), Times.Never);
+ _serviceClient.Verify(
+ sc => sc.UpdateCustomerForBuyerBillingAddressAsync(It.IsAny(), It.IsAny(),
+ It.IsAny(), It.IsAny()), Times.Never);
+ _serviceClient.Verify(sc => sc.CreateSubscriptionForCustomerAsync(_caller.Object, "acustomerid",
+ It.Is(sub =>
+ sub.EntityId == "anentityid"
+ && sub.EntityType == "anentitytype"
+ ), "aninitialplanid", starts.ToUnixSeconds(), Optional.None,
+ It.IsAny()));
+ }
+
+ [Fact]
+ public async Task WhenListAllPricingPlansAsyncAndCached_ThenReturnsCachedPlans()
+ {
+ var plans = new PricingPlans
+ {
+ Monthly = []
+ };
+ _pricingPlanCache.Setup(ppc => ppc.GetAsync(It.IsAny()))
+ .ReturnsAsync(plans.ToOptional());
+
+ var result = await _client.ListAllPricingPlansAsync(_caller.Object, CancellationToken.None);
+
+ result.Should().BeSuccess();
+ result.Value.Should().Be(plans);
+ _pricingPlanCache.Verify(ppc => ppc.GetAsync(It.IsAny()));
+ _pricingPlanCache.Verify(ppc => ppc.SetAsync(It.IsAny(), It.IsAny()),
+ Times.Never);
+ _serviceClient.Verify(sc =>
+ sc.ListActiveItemPricesAsync(It.IsAny(), It.IsAny(),
+ It.IsAny()), Times.Never);
+ _serviceClient
+ .Verify(sc => sc.ListSwitchFeaturesAsync(It.IsAny(), It.IsAny()),
+ Times.Never);
+ _serviceClient.Verify(sc =>
+ sc.ListPlanChargesAsync(It.IsAny(), It.IsAny(),
+ It.IsAny()), Times.Never);
+ _serviceClient.Verify(sc =>
+ sc.ListPlanEntitlementsAsync(It.IsAny(), It.IsAny(),
+ It.IsAny()), Times.Never);
+ }
+
+ [Fact]
+ public async Task WhenListAllPricingPlansAsync_ThenReturnsPlans()
+ {
+ _serviceClient.Setup(sc =>
+ sc.ListActiveItemPricesAsync(It.IsAny(), It.IsAny(),
+ It.IsAny()))
+ .ReturnsAsync(new List
+ {
+ CreatePlanItemPrice("aplanid", 3),
+ CreateChargeItemPrice("achargeid", 5)
+ });
+ _serviceClient
+ .Setup(sc => sc.ListSwitchFeaturesAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new List
+ {
+ CreateFeature("afeatureid")
+ });
+ _serviceClient.Setup(sc =>
+ sc.ListPlanChargesAsync(It.IsAny(), It.IsAny(),
+ It.IsAny()))
+ .ReturnsAsync(new List
+ {
+ CreateSetupAttachedItem("achargeid", "aplanid")
+ });
+ _serviceClient.Setup(sc =>
+ sc.ListPlanEntitlementsAsync(It.IsAny(), It.IsAny(),
+ It.IsAny()))
+ .ReturnsAsync(new List
+ {
+ CreateEntitlement("afeatureid")
+ });
+
+ var result = await _client.ListAllPricingPlansAsync(_caller.Object, CancellationToken.None);
+
+ result.Should().BeSuccess();
+ result.Value.Daily.Should().BeEmpty();
+ result.Value.Weekly.Should().BeEmpty();
+ result.Value.Monthly.Should().ContainSingle(plan =>
+ plan.Id == "anitempriceid"
+ && plan.Period.Frequency == 2
+ && plan.Period.Unit == PeriodFrequencyUnit.Month
+ && plan.Cost == 0.03M
+ && plan.SetupCost == 0.05M
+ && plan.Currency == "USD"
+ && plan.Description == "adescription"
+ && plan.DisplayName == "anexternalname"
+ && plan.FeatureSection[0].Features[0].IsIncluded == true
+ && plan.FeatureSection[0].Features[0].Description == "adescription"
+ && plan.IsRecommended == false
+ && plan.Notes == "someinvoicenotes"
+ && plan.Trial!.HasTrial == true
+ && plan.Trial.Frequency == 1
+ && plan.Trial.Unit == PeriodFrequencyUnit.Month
+ );
+ result.Value.Annually.Should().BeEmpty();
+ result.Value.Eternally.Should().BeEmpty();
+ _pricingPlanCache.Verify(ppc => ppc.GetAsync(It.IsAny()));
+ _pricingPlanCache.Verify(ppc => ppc.SetAsync(It.IsAny(), It.IsAny()));
+ _serviceClient.Verify(sc => sc.ListActiveItemPricesAsync(_caller.Object, "afamilyid",
+ It.IsAny()));
+ _serviceClient.Setup(sc => sc.ListSwitchFeaturesAsync(_caller.Object, It.IsAny()));
+ _serviceClient.Setup(sc => sc.ListPlanChargesAsync(_caller.Object, "aplanid", It.IsAny()));
+ _serviceClient.Setup(sc =>
+ sc.ListPlanEntitlementsAsync(_caller.Object, "aplanid", It.IsAny()));
+ }
+
+ [Fact]
+ public async Task WhenSearchAllInvoicesAsyncAndMissingCustomerId_ThenReturnsError()
+ {
+ var provider = BillingProvider.Create("aprovidername", new SubscriptionMetadata
+ {
+ { "aname", "avalue" }
+ }).Value;
+
+ var result = await _client.SearchAllInvoicesAsync(_caller.Object, provider, DateTime.UtcNow, DateTime.UtcNow,
+ new SearchOptions(), CancellationToken.None);
+
+ result.Should().BeError(ErrorCode.Validation, Resources.ChargebeeHttpServiceClient_InvalidCustomerId);
+ }
+
+ [Fact]
+ public async Task WhenSearchAllInvoicesAsync_ThenReturnsInvoices()
+ {
+ var from = DateTime.UtcNow.ToNearestSecond();
+ var to = DateTime.UtcNow.AddHours(1).ToNearestSecond();
+ var provider = BillingProvider.Create("aprovidername", new SubscriptionMetadata
+ {
+ { ChargebeeConstants.MetadataProperties.CustomerId, "acustomerid" }
+ }).Value;
+ _serviceClient.Setup(sc => sc.SearchAllCustomerInvoicesAsync(It.IsAny(), It.IsAny(),
+ It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new List
+ {
+ CreateInvoice("acustomerid")
+ });
+
+ var result = await _client.SearchAllInvoicesAsync(_caller.Object, provider, from, to,
+ new SearchOptions(), CancellationToken.None);
+
+ result.Should().BeSuccess();
+ var today = DateTime.Today.ToUniversalTime();
+ var yesterday = today.SubtractDays(1);
+ result.Value.Should().ContainSingle(invoice =>
+ invoice.Id == "aninvoiceid"
+ && invoice.Amount == 0.09M
+ && invoice.Currency == "USD"
+ && invoice.IncludesTax == true
+ && invoice.InvoicedOnUtc!.Value == today
+ && invoice.LineItems.Count == 1
+ && invoice.LineItems[0].Reference == "alineitemid"
+ && invoice.LineItems[0].Description == "adescription"
+ && invoice.LineItems[0].Amount == 0.09M
+ && invoice.LineItems[0].Currency == "USD"
+ && invoice.LineItems[0].IsTaxed
+ && invoice.LineItems[0].TaxAmount == 0.08M
+ && invoice.Notes.Count == 1
+ && invoice.Notes[0].Description == "anotedescription"
+ && invoice.Status == InvoiceStatus.Paid
+ && invoice.TaxAmount == 0.07M
+ && invoice.Payment!.Amount == 0.05M
+ && invoice.Payment.Currency == "USD"
+ && invoice.Payment.PaidOnUtc == today
+ && invoice.Payment.Reference == "atransactionid"
+ && invoice.PeriodStartUtc == yesterday
+ && invoice.PeriodEndUtc == today
+ );
+ _serviceClient.Verify(sc => sc.SearchAllCustomerInvoicesAsync(_caller.Object, "acustomerid",
+ from, to, It.IsAny(), It.IsAny()));
+ }
+
+ [Fact]
+ public async Task WhenChangeSubscriptionPlanAsyncAndMissingCustomerId_ThenReturnsError()
+ {
+ var provider = BillingProvider.Create("aprovidername", new SubscriptionMetadata
+ {
+ { ChargebeeConstants.MetadataProperties.SubscriptionId, "asubscriptionid" }
+ }).Value;
+
+ var result = await _client.ChangeSubscriptionPlanAsync(_caller.Object, new ChangePlanOptions
+ {
+ PlanId = "aplanid",
+ Subscriber = new Subscriber
+ {
+ EntityId = "anentityid",
+ EntityType = "anentitytype"
+ }
+ }, provider, CancellationToken.None);
+
+ result.Should().BeError(ErrorCode.Validation, Resources.ChargebeeHttpServiceClient_InvalidCustomerId);
+ _serviceClient.Verify(sc =>
+ sc.FindSubscriptionByIdAsync(It.IsAny(), It.IsAny(),
+ It.IsAny()),
+ Times.Never);
+ _serviceClient.Verify(
+ sc => sc.ChangeSubscriptionPlanAsync(It.IsAny(), It.IsAny(), It.IsAny(),
+ It.IsAny>(),
+ It.IsAny()), Times.Never);
+ }
+
+ [Fact]
+ public async Task WhenChangeSubscriptionPlanAsyncAndMissingSubscriptionId_ThenReturnsError()
+ {
+ var provider = BillingProvider.Create("aprovidername", new SubscriptionMetadata
+ {
+ { ChargebeeConstants.MetadataProperties.CustomerId, "acustomerid" }
+ }).Value;
+
+ var result = await _client.ChangeSubscriptionPlanAsync(_caller.Object, new ChangePlanOptions
+ {
+ PlanId = "aplanid",
+ Subscriber = new Subscriber
+ {
+ EntityId = "anentityid",
+ EntityType = "anentitytype"
+ }
+ }, provider, CancellationToken.None);
+
+ result.Should().BeError(ErrorCode.Validation, Resources.ChargebeeHttpServiceClient_InvalidSubscriptionId);
+ _serviceClient.Verify(sc =>
+ sc.FindSubscriptionByIdAsync(It.IsAny(), It.IsAny(),
+ It.IsAny()),
+ Times.Never);
+ _serviceClient.Verify(
+ sc => sc.ChangeSubscriptionPlanAsync(It.IsAny(), It.IsAny(), It.IsAny(),
+ It.IsAny>(),
+ It.IsAny()), Times.Never);
+ }
+
+ [Fact]
+ public async Task WhenChangeSubscriptionPlanAsyncAndActivated_ThenChangesPlan()
+ {
+ var provider = BillingProvider.Create("aprovidername", new SubscriptionMetadata
+ {
+ { ChargebeeConstants.MetadataProperties.CustomerId, "acustomerid" },
+ { ChargebeeConstants.MetadataProperties.SubscriptionId, "asubscriptionid" },
+ { ChargebeeConstants.MetadataProperties.SubscriptionStatus, Subscription.StatusEnum.Active.ToString() }
+ }).Value;
+ var datum = DateTime.UtcNow.ToNearestSecond();
+ var customer = CreateCustomer("acustomerid", false);
+ var subscription = CreateSubscription(customer, "asubscriptionid");
+ var changedSubscription = CreateSubscription(customer, "asubscriptionid", "aplanid2", datum, null, datum);
+ _serviceClient.Setup(sc =>
+ sc.FindSubscriptionByIdAsync(It.IsAny(), It.IsAny(),
+ It.IsAny()))
+ .ReturnsAsync(subscription.ToOptional());
+ _serviceClient.Setup(sc => sc.ChangeSubscriptionPlanAsync(It.IsAny(), It.IsAny(),
+ It.IsAny(), It.IsAny>(), It.IsAny()))
+ .ReturnsAsync(changedSubscription);
+
+ var result = await _client.ChangeSubscriptionPlanAsync(_caller.Object, new ChangePlanOptions
+ {
+ PlanId = "aplanid",
+ Subscriber = new Subscriber
+ {
+ EntityId = "anentityid",
+ EntityType = "anentitytype"
+ }
+ }, provider, CancellationToken.None);
+
+ result.Should().BeSuccess();
+ result.Value[ChargebeeConstants.MetadataProperties.CustomerId].Should().Be("acustomerid");
+ result.Value[ChargebeeConstants.MetadataProperties.SubscriptionId].Should().Be("asubscriptionid");
+ result.Value[ChargebeeConstants.MetadataProperties.BillingPeriodValue].Should().Be("0");
+ result.Value[ChargebeeConstants.MetadataProperties.BillingPeriodUnit].Should()
+ .Be(Subscription.BillingPeriodUnitEnum.Month.ToString());
+ result.Value[ChargebeeConstants.MetadataProperties.SubscriptionStatus].Should()
+ .Be(Subscription.StatusEnum.Active.ToString());
+ result.Value[ChargebeeConstants.MetadataProperties.SubscriptionDeleted].Should().Be(false.ToString());
+ result.Value[ChargebeeConstants.MetadataProperties.CurrencyCode].Should().Be("USD");
+ result.Value[ChargebeeConstants.MetadataProperties.PlanId].Should().Be("aplanid2");
+ result.Value[ChargebeeConstants.MetadataProperties.BillingAmount].Should().Be("0");
+ result.Value[ChargebeeConstants.MetadataProperties.TrialEnd].Should().Be(datum.ToIso8601());
+ result.Value[ChargebeeConstants.MetadataProperties.NextBillingAt].Should().Be(datum.ToIso8601());
+ _serviceClient.Verify(sc =>
+ sc.FindSubscriptionByIdAsync(_caller.Object, "asubscriptionid", It.IsAny()));
+ _serviceClient.Verify(
+ sc => sc.ChangeSubscriptionPlanAsync(_caller.Object, "asubscriptionid", "aplanid", Optional.None,
+ It.IsAny()));
+ }
+
+ [Fact]
+ public async Task WhenChangeSubscriptionPlanAsyncAndCanceling_ThenRemovesCancellationAndChangesPlan()
+ {
+ var provider = BillingProvider.Create("aprovidername", new SubscriptionMetadata
+ {
+ { ChargebeeConstants.MetadataProperties.CustomerId, "acustomerid" },
+ { ChargebeeConstants.MetadataProperties.SubscriptionId, "asubscriptionid" },
+ { ChargebeeConstants.MetadataProperties.SubscriptionStatus, Subscription.StatusEnum.Active.ToString() }
+ }).Value;
+ var datum = DateTime.UtcNow.ToNearestSecond();
+ var customer = CreateCustomer("acustomerid", false);
+ var subscription = CreateSubscription(customer, "asubscriptionid", status: Subscription.StatusEnum.NonRenewing);
+ var removedSubscription =
+ CreateSubscription(customer, "asubscriptionid", status: Subscription.StatusEnum.Active);
+ var changedSubscription = CreateSubscription(customer, "asubscriptionid", "aplanid2", datum, null, datum);
+ _serviceClient.Setup(sc =>
+ sc.FindSubscriptionByIdAsync(It.IsAny(), It.IsAny(),
+ It.IsAny()))
+ .ReturnsAsync(subscription.ToOptional());
+ _serviceClient.Setup(sc => sc.RemoveScheduledSubscriptionCancellationAsync(It.IsAny(),
+ It.IsAny(), It.IsAny()))
+ .ReturnsAsync(removedSubscription);
+ _serviceClient.Setup(sc => sc.ChangeSubscriptionPlanAsync(It.IsAny(), It.IsAny(),
+ It.IsAny(), It.IsAny>(), It.IsAny()))
+ .ReturnsAsync(changedSubscription);
+
+ var result = await _client.ChangeSubscriptionPlanAsync(_caller.Object, new ChangePlanOptions
+ {
+ PlanId = "aplanid",
+ Subscriber = new Subscriber
+ {
+ EntityId = "anentityid",
+ EntityType = "anentitytype"
+ }
+ }, provider, CancellationToken.None);
+
+ result.Should().BeSuccess();
+ result.Value[ChargebeeConstants.MetadataProperties.CustomerId].Should().Be("acustomerid");
+ result.Value[ChargebeeConstants.MetadataProperties.SubscriptionId].Should().Be("asubscriptionid");
+ result.Value[ChargebeeConstants.MetadataProperties.BillingPeriodValue].Should().Be("0");
+ result.Value[ChargebeeConstants.MetadataProperties.BillingPeriodUnit].Should()
+ .Be(Subscription.BillingPeriodUnitEnum.Month.ToString());
+ result.Value[ChargebeeConstants.MetadataProperties.SubscriptionStatus].Should()
+ .Be(Subscription.StatusEnum.Active.ToString());
+ result.Value[ChargebeeConstants.MetadataProperties.SubscriptionDeleted].Should().Be(false.ToString());
+ result.Value[ChargebeeConstants.MetadataProperties.CurrencyCode].Should().Be("USD");
+ result.Value[ChargebeeConstants.MetadataProperties.PlanId].Should().Be("aplanid2");
+ result.Value[ChargebeeConstants.MetadataProperties.BillingAmount].Should().Be("0");
+ result.Value[ChargebeeConstants.MetadataProperties.TrialEnd].Should().Be(datum.ToIso8601());
+ result.Value[ChargebeeConstants.MetadataProperties.NextBillingAt].Should().Be(datum.ToIso8601());
+ _serviceClient.Verify(sc =>
+ sc.FindSubscriptionByIdAsync(_caller.Object, "asubscriptionid", It.IsAny()));
+ _serviceClient.Setup(sc =>
+ sc.RemoveScheduledSubscriptionCancellationAsync(_caller.Object, "asubscriptionid",
+ It.IsAny()));
+ _serviceClient.Verify(
+ sc => sc.ChangeSubscriptionPlanAsync(_caller.Object, "asubscriptionid", "aplanid", Optional.None,
+ It.IsAny()));
+ }
+
+ [Fact]
+ public async Task WhenChangeSubscriptionPlanAsyncAndCanceled_ThenReactivatesAndChangesPlan()
+ {
+ var provider = BillingProvider.Create("aprovidername", new SubscriptionMetadata
+ {
+ { ChargebeeConstants.MetadataProperties.CustomerId, "acustomerid" },
+ { ChargebeeConstants.MetadataProperties.SubscriptionId, "asubscriptionid" },
+ { ChargebeeConstants.MetadataProperties.SubscriptionStatus, Subscription.StatusEnum.Active.ToString() }
+ }).Value;
+ var datum = DateTime.UtcNow.ToNearestSecond();
+ var customer = CreateCustomer("acustomerid", false);
+ var subscription = CreateSubscription(customer, "asubscriptionid", status: Subscription.StatusEnum.Cancelled);
+ var removedSubscription =
+ CreateSubscription(customer, "asubscriptionid", status: Subscription.StatusEnum.Active);
+ var changedSubscription = CreateSubscription(customer, "asubscriptionid", "aplanid2", datum, null, datum);
+ _serviceClient.Setup(sc =>
+ sc.FindSubscriptionByIdAsync(It.IsAny(), It.IsAny(),
+ It.IsAny()))
+ .ReturnsAsync(subscription.ToOptional());
+ _serviceClient.Setup(sc => sc.ReactivateSubscriptionAsync(It.IsAny(), It.IsAny(),
+ It.IsAny>(), It.IsAny()))
+ .ReturnsAsync(removedSubscription);
+ _serviceClient.Setup(sc => sc.ChangeSubscriptionPlanAsync(It.IsAny(), It.IsAny(),
+ It.IsAny(), It.IsAny>(), It.IsAny()))
+ .ReturnsAsync(changedSubscription);
+
+ var result = await _client.ChangeSubscriptionPlanAsync(_caller.Object, new ChangePlanOptions
+ {
+ PlanId = "aplanid",
+ Subscriber = new Subscriber
+ {
+ EntityId = "anentityid",
+ EntityType = "anentitytype"
+ }
+ }, provider, CancellationToken.None);
+
+ result.Should().BeSuccess();
+ result.Value[ChargebeeConstants.MetadataProperties.CustomerId].Should().Be("acustomerid");
+ result.Value[ChargebeeConstants.MetadataProperties.SubscriptionId].Should().Be("asubscriptionid");
+ result.Value[ChargebeeConstants.MetadataProperties.BillingPeriodValue].Should().Be("0");
+ result.Value[ChargebeeConstants.MetadataProperties.BillingPeriodUnit].Should()
+ .Be(Subscription.BillingPeriodUnitEnum.Month.ToString());
+ result.Value[ChargebeeConstants.MetadataProperties.SubscriptionStatus].Should()
+ .Be(Subscription.StatusEnum.Active.ToString());
+ result.Value[ChargebeeConstants.MetadataProperties.SubscriptionDeleted].Should().Be(false.ToString());
+ result.Value[ChargebeeConstants.MetadataProperties.CurrencyCode].Should().Be("USD");
+ result.Value[ChargebeeConstants.MetadataProperties.PlanId].Should().Be("aplanid2");
+ result.Value[ChargebeeConstants.MetadataProperties.BillingAmount].Should().Be("0");
+ result.Value[ChargebeeConstants.MetadataProperties.TrialEnd].Should().Be(datum.ToIso8601());
+ result.Value[ChargebeeConstants.MetadataProperties.NextBillingAt].Should().Be(datum.ToIso8601());
+ _serviceClient.Verify(sc =>
+ sc.FindSubscriptionByIdAsync(_caller.Object, "asubscriptionid", It.IsAny()));
+ _serviceClient.Setup(sc => sc.ReactivateSubscriptionAsync(_caller.Object, "asubscriptionid",
+ datum.ToUnixSeconds(), It.IsAny()));
+ _serviceClient.Verify(
+ sc => sc.ChangeSubscriptionPlanAsync(_caller.Object, "asubscriptionid", "aplanid", Optional.None,
+ It.IsAny()));
+ }
+
+ [Fact]
+ public async Task WhenChangeSubscriptionPlanAsyncAndUnsubscribed_ThenChangesPlan()
+ {
+ var provider = BillingProvider.Create("aprovidername", new SubscriptionMetadata
+ {
+ { ChargebeeConstants.MetadataProperties.CustomerId, "acustomerid" },
+ { ChargebeeConstants.MetadataProperties.SubscriptionId, "asubscriptionid" },
+ { ChargebeeConstants.MetadataProperties.SubscriptionDeleted, "true" }
+ }).Value;
+ var datum = DateTime.UtcNow.ToNearestSecond();
+ var customer = CreateCustomer("acustomerid", false);
+ var removedSubscription =
+ CreateSubscription(customer, "asubscriptionid", status: Subscription.StatusEnum.Active);
+ var changedSubscription = CreateSubscription(customer, "asubscriptionid", "aplanid2", datum, null, datum);
+ _serviceClient.Setup(sc => sc.CreateSubscriptionForCustomerAsync(It.IsAny(), It.IsAny(),
+ It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny>(),
+ It.IsAny()))
+ .ReturnsAsync(removedSubscription);
+ _serviceClient.Setup(sc => sc.ChangeSubscriptionPlanAsync(It.IsAny(), It.IsAny(),
+ It.IsAny(), It.IsAny>(), It.IsAny()))
+ .ReturnsAsync(changedSubscription);
+
+ var result = await _client.ChangeSubscriptionPlanAsync(_caller.Object, new ChangePlanOptions
+ {
+ PlanId = "aplanid",
+ Subscriber = new Subscriber
+ {
+ EntityId = "anentityid",
+ EntityType = "anentitytype"
+ }
+ }, provider, CancellationToken.None);
+
+ result.Should().BeSuccess();
+ result.Value[ChargebeeConstants.MetadataProperties.CustomerId].Should().Be("acustomerid");
+ result.Value[ChargebeeConstants.MetadataProperties.SubscriptionId].Should().Be("asubscriptionid");
+ result.Value[ChargebeeConstants.MetadataProperties.BillingPeriodValue].Should().Be("0");
+ result.Value[ChargebeeConstants.MetadataProperties.BillingPeriodUnit].Should()
+ .Be(Subscription.BillingPeriodUnitEnum.Month.ToString());
+ result.Value[ChargebeeConstants.MetadataProperties.SubscriptionStatus].Should()
+ .Be(Subscription.StatusEnum.Active.ToString());
+ result.Value[ChargebeeConstants.MetadataProperties.SubscriptionDeleted].Should().Be(false.ToString());
+ result.Value[ChargebeeConstants.MetadataProperties.CurrencyCode].Should().Be("USD");
+ result.Value[ChargebeeConstants.MetadataProperties.PlanId].Should().Be("aplanid2");
+ result.Value[ChargebeeConstants.MetadataProperties.BillingAmount].Should().Be("0");
+ result.Value[ChargebeeConstants.MetadataProperties.TrialEnd].Should().Be(datum.ToIso8601());
+ result.Value[ChargebeeConstants.MetadataProperties.NextBillingAt].Should().Be(datum.ToIso8601());
+ _serviceClient.Verify(
+ sc => sc.FindSubscriptionByIdAsync(It.IsAny(), It.IsAny(),
+ It.IsAny()), Times.Never);
+ _serviceClient.Setup(sc => sc.CreateSubscriptionForCustomerAsync(_caller.Object, "acustomerid",
+ It.Is(sub =>
+ sub.EntityId == "anentityid"
+ && sub.EntityType == "anentitytype"
+ ), "aplanid", Optional.None, Optional.None, It.IsAny()));
+ _serviceClient.Verify(
+ sc => sc.ChangeSubscriptionPlanAsync(_caller.Object, "asubscriptionid", "aplanid", Optional